Skip to content
investiDocs

REST API

Read your Investi research, portfolio and ledger over plain HTTPS at /api/v1 with a personal access token. No endpoint can trade.

On this page

Reporting corrections and timing#

GET /api/v1/cost-basis (portfolio:read) returns startDate and affected transaction rows. PUT requires an app session and workspace:write; its JSON body is { "executionId": "...", "totalBasis": 1234.56, "acquiredOn": null }. Acquisition date is optional, or a real YYYY-MM-DD date on or before the transaction. DELETE takes { "executionId": "..." } and removes the correction. Both mutations are audited and leave imported rows intact. API bearer tokens cannot change basis.

GET /api/v1/research-refresh (research:read) returns pending, failed (awaiting retry), and latest (queue completion timestamp).

Settings now include reportingStartDate (default 2026-01-01), valuationMode (broker or live), and partial calendar preferences (excludePast, holdings, notable, economic). Notable earnings and economic releases default off.

POST /api/v1/schwab claims a background sync and returns status: "running" with runId, or busy with the current run. Poll GET /api/v1/schwab for recorded endpoint progress and final outcome. Balances and positions for all accounts are updated before historical orders and transactions.

Authenticated API responses include Server-Timing: app measures handler elapsed time, db sums database operation durations, and upstream sums instrumented fetch durations. Concurrent operations can overlap, so summed durations may exceed elapsed time. Existing route-specific metrics remain present. No SQL, URLs or credentials are included. Stream durations describe response setup, not the lifetime of the stream.

The REST API serves the same server-side calculations as the web app, the Claude connector and the Chrome extension, so every number matches across surfaces. It lives at /api/v1 on your Investi deployment and is described by an OpenAPI 3.1 document. No endpoint places, changes or cancels a broker order.

The examples on this page use $INVESTI for your deployment's origin (https://investistocks.vercel.app for the hosted Investi, or your own origin if you self-host) and $TOKEN for a personal access token.

Shell
export INVESTI=https://investistocks.vercel.app
export TOKEN=inv_pat_your_token_here

Base path and OpenAPI document#

Every endpoint is under /api/v1. The machine-readable description is at /api/v1/openapi.json. It needs no authentication, uses a relative server URL (/api/v1), and is cached for five minutes.

Shell
curl -s "$INVESTI/api/v1/openapi.json" | head -c 400

The document lists every operation with its tag, summary and required scope, and includes the full JSON Schema of the canonical company report under components.schemas.CompanyReport. Other response bodies are typed only as object in the document; the shapes on this page come from the route handlers.

Authentication#

Every endpoint except the OpenAPI document requires one of two credentials.

Personal access tokens#

Send a bearer token in the Authorization header:

Shell
curl -s "$INVESTI/api/v1/portfolio" -H "Authorization: Bearer $TOKEN"
  • Tokens start with inv_pat_ followed by 43 random URL-safe characters.
  • Create one in Settings, under the browser extension and API tokens panel of Connections. The token is shown once. Investi stores only its SHA-256 hash, so it cannot show it to you again.
  • The Create token button in Settings makes a read-only token with market:read portfolio:read research:read. The list beside it shows each token's name, scopes and when it was last used, with a Revoke link.
  • Personal access tokens do not expire. Revoke a token you no longer use.
  • OAuth access tokens issued to MCP clients (prefix inv_at_) are also accepted as bearer tokens, with the scopes the user granted on the consent screen. See Claude (MCP).

If a bearer header is present, Investi uses only the token. An invalid, revoked or expired token returns 401; it never falls back to a session cookie.

Scopes#

Scope Grants
market:read Quotes, price history and market session
portfolio:read Positions, orders, transactions and round trips
research:read Company research, verdicts and reports
workspace:write Save notes, watchlists and alerts in Investi (never broker writes)
simulate Run hypothetical buy and sell and stress simulations (never submits orders)

There is deliberately no scope that can trade.

To get a token with workspace:write or simulate, create it with POST /api/v1/tokens from a signed-in browser tab on your Investi site (token management accepts only session cookies). For example, in the browser console on your Investi origin:

JavaScript
await fetch("/api/v1/tokens", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Notes script", scopes: ["research:read", "workspace:write"] }),
}).then((r) => r.json());
// { token: "inv_pat_...", id: "<12 hex chars>", note: "Shown once. ..." }

name is 1 to 80 characters and scopes must list at least one valid scope.

Requests from the web app itself use the investi_session cookie and get all five scopes. For state-changing methods (POST, PUT, PATCH, DELETE) with cookie auth, Investi checks that the request is same-origin: the Origin header's host must equal the request host, or, when there is no Origin header, Sec-Fetch-Site must be same-origin. A cross-origin request is rejected with 403 and code csrf. Bearer-token requests skip this check because browsers never attach them automatically.

Session-only endpoints#

Some actions are refused for bearer tokens even when the token has the listed scope. They return 403 with code session_required and must be done in the app:

  • GET /tokens, POST /tokens, DELETE /tokens/{id}
  • PUT /policy (strategy rules)
  • PUT /settings
  • POST /schwab (sync) and DELETE /schwab (disconnect)
  • POST /sample and DELETE /sample
  • POST /uploads/token (direct-to-Blob upload tokens)
  • POST /playbook/apply (it writes a strategy-rules version)

Responses and errors#

Successful responses are JSON with Cache-Control: private, no-store, except the CSV export and the event stream. Many list endpoints wrap rows in data and add a provenance object that names the source and when it was observed.

GET /portfolio, /orders, /transactions, /securities/{ticker}/history, /securities/{ticker}/intel and the company report also carry freshness: one { observedAt, source, through?, stale?, note? } stamp per dataset (quote, bars, fundamentals, filings, catalysts, analyst), and one per source for orders, fills, balances and positions, plus the Schwab connection (freshness.schwab.state: LIVE, DELAYED, STALE, FAILED or REAUTH_REQUIRED, computed from finished sync runs only). The OpenAPI document describes it as the DataFreshness schema. See Freshness on every dataset. These fields were added alongside the existing ones; nothing was renamed.

Errors use one envelope:

JSON
{ "error": { "code": "insufficient_scope", "message": "Token lacks scope workspace:write" } }
Status code When
400 invalid_request The body failed validation. The envelope also carries an issues array.
400 invalid_json The body is not JSON.
400 bad_id A token id is not 12 hex characters.
400 inconsistent_thresholds PUT /policy thresholds contradict each other (for example, entry gates that do not widen as ATR rises). The message lists every problem.
401 unauthorized No valid session or bearer token. The response includes a WWW-Authenticate: Bearer resource_metadata="..." header pointing at /.well-known/oauth-protected-resource.
403 insufficient_scope The token lacks the endpoint's scope.
403 csrf Cookie-authenticated write from another origin.
403 session_required The action is session-only (see above).
404 not_found Unknown symbol, unknown export kind, or missing record.
413 too_large An upload is larger than 20 MB.
429 rate_limited POST /ai/page made more than 30 fresh analyses for you in 10 minutes (cached answers do not count), or POST /ai/chat got more than 60 messages in 10 minutes.
500 internal Unexpected server error. Details are logged on the server, not returned.
503 ai_not_configured POST /ai/page or POST /ai/chat was called on a server with no AI provider configured.
503 ai_unavailable An AI endpoint could not produce an answer: no provider configured (explanations), the provider failed, timed out or returned an unreadable answer.

Some handlers raise other specific codes with the same envelope. The event stream is the exception: it returns a plain-text 401 Unauthorized instead of the JSON envelope.

Pagination#

GET /transactions, GET /orders and GET /orders/sweep are paginated. Transactions return fills newest first and use a cursor:

Parameter Meaning
limit 1 to 500, default 100
ticker Optional, filters to one ticker
cursor The nextCursor value from the previous page

The response is { data, nextCursor, provenance }. nextCursor is null on the last page. Treat the cursor as opaque.

GET /orders and GET /orders/sweep take limit (1 to 200, default 50) and cursor (or offset), and answer page: { total, offset, limit, returned, nextCursor }. Other list endpoints return everything or a fixed cap (for example, notes return at most 300 rows; limit narrows that).

Open orders#

Probe lots (positions and orders of the strategy rule's probe size or fewer, 1 share by default) stay in GET /portfolio (each position has probe, and the answer has probes: { maxShares, positions, orders }) and in GET /orders (each row has probe, the summary counts probes). GET /orders/sweep leaves them out and reports probesOmitted. See Probe lots.

GET /orders answers the same object as the get_open_orders MCP tool. Without parameters it returns the working orders' summary and the 50 largest by notional, which stays under about 20,000 characters even for several hundred orders.

Parameter Meaning
ticker Comma list of tickers
side BUY or SELL
session GTC, GTC_EXT (GTC or GTD that also works in extended hours, Schwab's GTC+Ext), DAY or OTHER
minNotional Minimum remaining notional in USD
earningsWithinDays Only names whose next report is within N days
verdict Comma list of GREEN, YELLOW, RED, FLAGGED, DORMANT, NONE (never evaluated)
mode both (summary plus one page, the default), summary or orders
sort notional (default), ticker, distance, earnings or entered
status working (default) or all

The summary carries count, tickers, totalNotional by side, bySession, gtcExtCount, earningsWithin10Days, onRedOrFlagged and earningsUnknown. Each row has the remaining shares, limit, last, distancePct (limit against last, negative means below), notional, session, earnings (date, daysTo, confirmed), verdict and gateCeiling (the policy entry-gate price).

Shell
curl -s "https://investistocks.vercel.app/api/v1/orders?mode=summary&session=GTC_EXT&earningsWithinDays=10" \
  -H "Authorization: Bearer $INVESTI_TOKEN"

Two filters and two opt-in field sets were added; nothing was renamed and the default answer is unchanged apart from summary.flags (working orders per flag). flag (comma list) keeps orders carrying any of these flags, never set on probe orders: earnings (the name reports within 10 trading days and the order is GTC_EXT), verdict (the verdict differs from the one in force when the order was entered), stale (open more than 60 days), far (last more than 15% from the limit) and duplicate (more than one working order on the same symbol, side and price, across accounts; a ladder at different prices is not a duplicate). include=flags adds each row's flags ({ id, label }), ageDays, verdictAtEntry and verdictDate (filtering by flag adds them too); include=research adds research: { account, sector, exDividend, low52, pctAboveLow, analystTarget, upsideToTarget } (fractions, null where the source has nothing). Both are in the Open orders screen.

Holdings#

GET /holdings answers { asOf, rows, summary, pending, definitions }, the same builder as the Holdings screen and the get_holdings MCP tool. Each row: ticker, account, shares, avgCost (Schwab's basis when reported; basisSource says which), last, marketValue, unrealized, unrealizedPct, firstBuyDays, oldestLotDays, ageBucket, lotsCount, lots (date, shares, cost, ageDays, account), verdict, verdictDate, verdictLines, nextEarnings, daysToEarnings, exDividend, lastBuyDate, lastSellDate, low52, high52, pctAboveLow, analystAvg, analystLow, analystHigh, upsideToAvg, analystCount, sellOrder and buyOrder (price, distance, count), sector, bucket and buckets, dividends, breakeven and probe. Percentages are fractions. summary has totalMarketValue, totalUnrealized, underwaterCount, underwaterAged (days, value, share), losers (five largest losses) and probes. pending lists symbols whose earnings, dividend or analyst data had not answered yet; their fields are null.

Shell
curl -s "https://investistocks.vercel.app/api/v1/holdings" -H "Authorization: Bearer $INVESTI_TOKEN" | jq '.rows[0] | {ticker, unrealizedPct, oldestLotDays, verdict, bucket}'

Order risk sweep#

GET /orders/sweep gives one suggested action per working order, most urgent first, as the order_risk_sweep MCP tool does. The rules, strongest first: cancel for a buy on a RED or FLAGGED name; switch to plain GTC when the name reports within earningsSoonDays (default: your policy's earnings call-out, 7 days; confirmed or estimated dates) and the order is GTC_EXT; review the gate when a buy limit is more than gateTolerancePct (default 1) above the entry-gate ceiling; otherwise OK. OK rows are left out unless includeOk=true. The suggestions are research text: Investi never places, changes or cancels an order, and the response says so in its first field, notice.

Digest and webhooks#

GET /digest (needs research:read and portfolio:read) returns the weekly digest: pnl (week and month realized on average cost, today, lifetime realized, unrealized, dividends, fees, margin interest and net), orders (the open-orders summary), orderRiskSweep, the full weeklyReview, connection (the Schwab state from finished sync runs) and broker (how old the share counts are and whether positions are verified, unverified or unreconciled). Treat the figures as unverified unless broker.reconciliation is verified. The get_digest MCP tool returns the same document.

Investi has no email delivery, so the digest is pushed to webhooks instead. Register an HTTPS URL in Settings, Digest or with POST /webhooks ({ "url": "https://..." }); the response carries the signing secret once. Every Sunday at 22:00 UTC Investi POSTs the digest to each active webhook as { id, event: "digest.weekly", createdAt, data }. POST /webhooks/{id}/test sends it at once as digest.test.

Each delivery has X-Investi-Event, X-Investi-Delivery and X-Investi-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw body> keyed with the secret. Verify it over the raw body and reject timestamps older than five minutes:

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header) {
  const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header) ?? [];
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return timingSafeEqual(expected, Buffer.from(v1, "hex"));
}

Registering, listing and testing webhooks needs workspace:write, portfolio:read and research:read, because the digest carries portfolio and research data. Investi records which credential registered each webhook: if a personal token or connected app registered it, revoking that token or app pauses the webhook, and every delivery re-checks the credential first.

URLs must be https, without credentials, and resolve only to public global-unicast addresses (IPv6 forms that embed an IPv4 address, such as 6to4 and Teredo, are judged by that address or refused); redirects are not followed. Each delivery has a hard 10-second total limit, so a receiver should answer 2xx promptly. Deliveries that do not fit in the Sunday run's time budget are skipped, not counted as failures, and go out the next week. After five failed weekly deliveries in a row the webhook is paused; a successful test turns it back on.

CORS and rate limits#

/api/v1 endpoints send no CORS headers and have no OPTIONS handlers, so a page on another origin cannot call them from a browser. Call the API from a server, a script, or the Investi origin itself. The OpenAPI document is the one exception: it sends Access-Control-Allow-Origin: *.

Apart from the two AI budgets above, the API has no rate limiting of its own. Login is the only other rate-limited endpoint; see Security and privacy.

Examples#

Manual check answers#

Shell
curl -s -X PUT "$INVESTI/api/v1/securities/DOX/checks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"checkId":"manual.revealed","answer":"PASS","note":"Guidance cut covers the drop","expiresAt":"2027-02-02"}'

GET lists your answers for the ticker with active (still feeding the verdict) and DELETE ?checkId=manual.revealed removes one. checkId is manual.kodak, manual.legal or manual.revealed; answer is PASS, FAIL or UNKNOWN; expiresAt defaults to 90 days and may not be in the past or more than two years out. Rule outcomes in the report can carry an optional evidence list (date, source, title, snippet, url), and the cap severity was added in policy v3; both are additive to report.v2.

Company report#

Shell
curl -s "$INVESTI/api/v1/securities/SPXC/report" \
  -H "Authorization: Bearer $TOKEN"

Needs research:read. Returns the canonical company report, the same object the MCP tools use. Its top-level fields include schemaVersion, reportId, generatedAt, identity, quote, tradingHistory, businessQuality, priceSetup, catalysts, analystViews, strategyVerdict, buyZone, framework, bearBull, changeLog, provenance and disclaimer. The exact schema is in the OpenAPI document. An unrecognized symbol returns 404 not_found.

catalysts.items in report.v2 holds the same catalyst objects as the catalysts route below, as of when the report was built. The report waits only 1.2 seconds for them, so slow kinds can be missing, and reports stored before catalysts existed have an empty list.

Catalysts#

Shell
curl -s "$INVESTI/api/v1/securities/SPXC/catalysts" \
  -H "Authorization: Bearer $TOKEN"

Needs research:read and takes no query parameters. The body is the catalyst feed itself, with no data envelope: the last 60 days plus upcoming earnings, ex-dividend and macro events, newest first, capped at 80 items. An unrecognized symbol returns 404 not_found. Responses are Cache-Control: private, no-store. It is the feed behind the research page's What could move it section, the MCP tool get_catalysts, and the hover card's Catalysts section (insights.catalysts in GET /securities/{ticker}/intel?insights=1, which is null with catalysts listed in pending until the feed is built).

JSON
{
  "ticker": "SPXC",
  "asOf": "2026-11-03T14:05:00.000Z",
  "items": [
    {
      "id": "analyst:argus-2026-11-03",
      "kind": "analyst",
      "at": "2026-11-03T13:00:00.000Z",
      "upcoming": false,
      "title": "Argus raised price target to $400 from $375",
      "detail": null,
      "source": "nasdaq.com",
      "url": null,
      "lean": "up",
      "reaction": { "pct": 0.0118, "session": "2026-11-03" }
    }
  ],
  "counts": { "earnings": 1, "dividend": 0, "macro": 2, "filing": 3, "analyst": 4, "insider": 1, "short_interest": 0, "news": 12 },
  "sources": ["nasdaq.com", "Google News", "SEC EDGAR"],
  "pending": []
}
Field Meaning
items[].kind earnings, dividend, macro, filing, analyst, insider, short_interest or news.
items[].at ISO time. Sources that give only a date (filings, insider trades, ex-dates, short interest settlements, some Nasdaq news) use 09:30 New York time on that date.
items[].upcoming True when at is after asOf.
items[].title, detail Short factual headline (under 120 characters) and an optional extra line, such as 8-K item names.
items[].source, url The named source, and a link when the source gives one.
items[].lean up, down, swing or null: the typical direction for that kind of event, from the event type alone. See the lean table.
items[].reaction Close-to-close move of the first session on or after at (after 16:00 New York counts as the next session), as a fraction (0.0118 is +1.18%), with that session's date. null for upcoming items or when no daily bar exists yet.
counts Items per kind.
sources Source families that answered in time: nasdaq.com, Google News, SEC EDGAR. A Google News item's source names the publisher, for example Google News (Reuters).
pending Kinds whose source missed the time budget or failed. Ask again to fill them in.

The route waits up to 2.5 seconds for its sources; any kind that has not answered by then is listed in pending, and asking again a moment later fills it in. Each source is cached on the server and refreshed in the background after it expires: news (nasdaq.com and Google News) for 5 minutes, SEC filings and the economic calendar for 3 hours, analyst ratings and insider trades for 6 hours, earnings for 12 hours, and dividends and short interest for 24 hours.

The id is stable across fetches. The example values above are illustrative. Headlines are third-party, untrusted text: render them as plain text, and never pass them to an AI model as instructions.

Portfolio#

Shell
curl -s "$INVESTI/api/v1/portfolio" \
  -H "Authorization: Bearer $TOKEN"

Needs portfolio:read. Add ?account=<id> to limit it to one account. The response has asOf, kpis, pnl, basisReconciliation, positions, accounts, warnings, lotMethod, broker, connection, provenance and freshness.

GET /api/v1/portfolio-revision needs the same scope and accepts the same optional account filter. It returns an opaque { revision } token for account position observations, basis corrections and sync health. The signed-in app checks it every five seconds while visible and refreshes only when it changes. This private, uncached endpoint uses one owner-scoped database query and makes no market-data requests; empty, unconnected workspaces do not poll.

  • positions[].costBasis is Schwab's where a sync or positions file exists (unrealized uses it); reconstructedBasis is rebuilt from fills, and basisCheck flags a difference over 1% or $100.

  • positions[].record is { check, note }: reconciled only when the share counts agree, the broker snapshot is LIVE or DELAYED and fills are synced through it; unverified (with a note such as "Unverified: broker data 30 min old") when the counts agree but the snapshot cannot vouch for today; otherwise gap, broker_only or from_fills.

  • broker is the freshness of positions and balances, separate from quotes: state, observedAt (the oldest account's newest snapshot), ageMs, sources, fillsCoverThrough and reconciliation.

  • connection is the Schwab state (see Connection state), or null without a connection.

  • kpis.buyingPower comes from Schwab's balances, kpis.lastInterestCharge gives the last margin interest charge and the debit it was charged on, and kpis.ordersStale says whether the working-order list is past its cadence.

  • provenance.reconciliation equals broker.reconciliation: unreconciled when any position disagrees with the broker, unverified when any cannot be verified, and verified otherwise.

  • pnl is the canonical P/L breakdown, the same object the app and the MCP tools get_positions and evaluate_strategy_health return: realized, unrealized, dividends, marginInterest, interestEarned, accountFees, tradingFees (already inside realized and basis), net, and what is excluded: legacy (sales of shares bought before your history: proceeds, shares, sells, tickers, firstDate, lastDate), beforeCoverage (cash dated before your first fill), otherCash (unclassified cash) and unpriced (positions with no quote). See Canonical numbers.

  • Each position carries brokerBasis, reconstructedBasis (rebuilt from your fills), basisDiff and basisCheck: match, differs (more than 1% or $100 apart), broker_only (no buys on record), reconstructed_only or unknown.

  • basisReconciliation totals the broker snapshot: observedAt, sources, brokerBasis, brokerMarketValue, brokerUnrealized (at the snapshot time), comparedBrokerBasis and comparedReconstructedBasis over the positions that have both, flagged, brokerOnly and unrealizedAtQuotes.

Quotes#

Shell
curl -s "$INVESTI/api/v1/quotes?symbols=SPXC,AAPL,BRK.B" \
  -H "Authorization: Bearer $TOKEN"

Needs market:read. Up to 400 symbols per call. The response is { data, unknown, provenance }: data holds one quote per recognized symbol with a freshness label, unknown lists symbols that did not resolve, and provenance.delayedBy is 900 seconds. Quotes are delayed; see Data sources.

For a live feed, GET /stream?symbols=... is a server-sent events stream of feed, quote and heartbeat events (up to 200 symbols). With a connected Schwab account it pushes real-time Schwab Streamer ticks, at most one per symbol every 500 ms; otherwise it polls delayed Nasdaq every 5 seconds. A feed event names the source whenever it changes. It sends only changed snapshots and ends after about 4.5 minutes, so reconnect when it closes; sequence numbers keep rising across reconnects.

Transactions, page by page#

Shell
# First page
curl -s "$INVESTI/api/v1/transactions?limit=200&ticker=SPXC" \
  -H "Authorization: Bearer $TOKEN" > page1.json

# Next page: pass nextCursor back, URL-encoded
CURSOR=$(jq -r '.nextCursor' page1.json)
curl -s -G "$INVESTI/api/v1/transactions" \
  --data-urlencode "limit=200" \
  --data-urlencode "ticker=SPXC" \
  --data-urlencode "cursor=$CURSOR" \
  -H "Authorization: Bearer $TOKEN"

Needs portfolio:read. Each row has id, account_id, instrument_id, ticker, side, quantity, price, fees, trade_date, source, basis_source and import_batch_id. Stop when nextCursor is null.

Create a note#

Shell
curl -s -X POST "$INVESTI/api/v1/notes" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticker":"SPXC","kind":"thesis","body":"Adding only inside the buy zone. Recheck after the next 10-Q."}'

Needs workspace:write, so the default read-only token from Settings is not enough (see Scopes). The body takes body (1 to 20,000 characters), an optional ticker, kind (note, decision or thesis, default note) and an optional evaluationId. The response is { "id": "note_..." }. An unknown ticker returns 404 not_found. Note creation is recorded in the audit log.

Ask about a screen#

Shell
curl -s "$INVESTI/api/v1/ai/chat" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/research/SPXC", "message": "Where is my exit target?"}'
# { "threadId": "thr_...", "assistant": { "content": "...", "model": "..." }, "changes": [], "proposals": [], ... }

With "stream": true the response is application/x-ndjson: {"type":"delta","text":"..."} lines as the model writes, then one {"type":"done", ...} with the same fields, or {"type":"error","code":"...","message":"..."}. See AI page analysis and Uploads, playbook and memory.

Endpoint reference#

Paths are relative to /api/v1. "Session" means the endpoint accepts only the session cookie.

Market#

Method Path Scope Summary
GET /search market:read Search securities by ticker or company name (?q=)
GET /quotes market:read Delayed quotes for ?symbols=A,B,C
GET /stream market:read Server-sent events stream of delayed quotes (?symbols=)
GET /securities/{ticker}/history market:read Daily split-adjusted bars (?range=5D, 1M, 3M, 6M, 1Y, 5Y or ALL; default 1Y)
GET /securities/{ticker}/intraday market:read Intraday prints for the latest session

Research#

Method Path Scope Summary
GET /securities/{ticker}/report research:read Canonical company report (same object the MCP tools use)
GET /securities/{ticker}/catalysts research:read Catalyst feed: dated news, analyst actions, insider trades, filings and scheduled events, with lean and reaction
GET /securities/{ticker}/intel portfolio:read Compact hover intel (intel.v1) for the extension and hover cards (?expanded=1 adds detail)
POST /securities/{ticker}/explain research:read Plain-language explanation of the current report (needs ANTHROPIC_API_KEY)
POST /ai/page research:read and portfolio:read AI summary and one research recommendation for an app screen. Body { "path": "/portfolio", "refresh": false }. See AI page analysis
POST /securities/{ticker}/override workspace:write Record a verdict override with rationale
GET /securities/{ticker}/valuation research:read Saved DCF assumptions
PUT /securities/{ticker}/valuation workspace:write Save DCF assumptions

Portfolio#

Method Path Scope Summary
GET /portfolio portfolio:read Positions, KPIs and broker reconciliation
GET /portfolio-revision portfolio:read Change token for positions, basis corrections and sync health
GET /framework/regime portfolio:read Market regime switch: IWM against its moving average plus your own recent fills, with the last 15 sessions of history
GET /framework/risk portfolio:read Portfolio risk: crash-cap ratio, concentration clusters, unrealized-loss band and underwater age
GET /framework/weekly portfolio:read Weekly review for the most recent completed week (the same object as the get_weekly_review MCP tool), with reportingToday, reportingThisWeek, estimatedDates and heldThroughPrint
GET /digest research:read and portfolio:read Weekly digest: P/L breakdown, open-orders summary, order risk sweep and weekly review
GET /actions portfolio:read The action list (the same object as the /actions screen and the get_action_list MCP tool): items (one line per action: key, group now, week or watch, verb, symbol, text, detail, and state done, snoozed or dismissed when hidden, else null), counts (showing per group, and hidden), switchToGtc, ordersOnRedOrFlagged, heldReporting, breaches, crashCap (with level, limit and ifBuysFill), summary, accounts, plus schwabOrderStatusUrl. The sections, openItems and summary leave hidden lines out. Each order carries its Schwab externalId
POST /actions/{key}/state workspace:write Mark one line (key from items[].key) with body { "state": "done" | "snoozed" | "dismissed" | "open" | "undo" }. Done and dismissed hide it until its facts change (a replaced order is a new key); snoozed hides it until the next trading day; open shows it again; undo restores the state before the last change. Returns action (state, until, undoable). Inside Investi only: never touches an order
GET /transactions portfolio:read Ledger transactions (cursor paginated)
GET /round-trips portfolio:read Flat-to-flat round trips with realized P/L (?ticker=)
GET /sold portfolio:read What was sold on a New York date (?date=YYYY-MM-DD, default today), one row per symbol and account, with the day's close
GET /holdings portfolio:read Every held position as one row with the summary strip (see Holdings)
GET /orders portfolio:read Working orders with filters, a summary and pages (see Open orders)
GET /orders/sweep portfolio:read Order risk sweep: a suggested action per working order (see Order risk sweep)
POST /orders workspace:write Record an order you placed elsewhere
PATCH /orders/{id} workspace:write Update a recorded order's status
GET /imports portfolio:read Import batches
POST /imports workspace:write Import Schwab CSV or JSON, generic CSV, positions CSV or Order Status CSV (kind = transactions, positions or orders)
POST /imports/verdicts workspace:write Import a verdict tracker: multipart field file, CSV or XLSX with Ticker, Verdict and optional Date and Notes columns. Returns inserted, duplicates, accepted, skipped (row, ticker, reason), warnings and byVerdict. See Import history
DELETE /securities/{ticker}/override workspace:write Clear your verdict on a name (imported or manual): it stops carrying forward and the engine's verdict becomes current. The report's userVerdict (verdict, source, at, rationale, engineVerdict) shows a carried verdict
DELETE /imports/{id} workspace:write Delete an import batch and the records it owns
GET /export/{kind} portfolio:read CSV export. kind is fills, lots or round-trips
GET /schwab portfolio:read Schwab connection status
POST /schwab workspace:write, session Sync Schwab now (read-only GETs)
DELETE /schwab workspace:write, session Disconnect Schwab and delete stored tokens

Screener#

Method Path Scope Summary
GET /screener/runs research:read Recent screener runs
POST /screener/runs research:read Start a resumable 52-week-low screener run
GET /screener/runs/{id} research:read Run progress
POST /screener/runs/{id}/step research:read Advance a run by one batch
GET /screener/candidates research:read Candidates with state (NEW, CHANGED, OWNED and others)
POST /screener/analyze research:read Evaluate queued candidates against the policy
GET /screens research:read Saved screens
POST /screens workspace:write Save a screen
DELETE /screens/{id} workspace:write Delete a saved screen
POST /screens/import workspace:write Upload a Schwab Stock Screener or Finviz CSV export; every row becomes a candidate
GET /candidates research:read New candidate entrants from near low, big drop, Strict 1 and uploads
POST /candidates/{ticker}/dismiss workspace:write Dismiss a candidate until it trades below the price it was dismissed at
DELETE /candidates/{ticker}/dismiss workspace:write Undo a dismissal

Candidates#

GET /api/v1/candidates lists new candidate entrants, newest first. How each source works is in Candidate sources.

Parameter Values
source near_low, big_drop, strict or upload
sinceDays Only names first seen within this many days
verdict GREEN, YELLOW, RED, FLAGGED, DORMANT, or NONE for names not yet evaluated
includeDismissed true to include dismissed names
newOnly false to include names seen before and already evaluated, and owned names (default true)
limit, page Page size (1 to 200, default 50) and page number (from 1)

The response has entrants, total, counts per source (all, near_low, big_drop, strict, upload), unfiltered (entrants before the verdict and since-days filters), sourceStatus (per source: lastRunAt, state of ok, never_run or degraded, and a note such as "Change feed failed at 2:15 PM ET (too few rows); one-day change from the last two stored daily closes"), rules (your near-low distance, drop threshold, drop sessions and cap floor), newDays, lastVisit, page and nextPage (null on the last page). Page through with nextPage to read the whole list. Each entrant carries sources (each with source, firstSeenAt, lastSeenAt and meta), firstSeenAt, price, changePct, pctAboveLow, verdict (null when not yet evaluated), owned, isNew, dismissed, reentered, reason, why (a short reason computed from its sources, such as "-18.2% in 5d", "new 52W low", "Strict 1: 11/11" or "uploaded Nov 4"), strict (the eleven checks, each pass, fail or unknown, with value and detail) and drop (the big-drop gate's checks, with changePct, sessions and changeSource of feed or bars).

Sold on a day#

GET /api/v1/sold?date=YYYY-MM-DD (portfolio:read) answers what was sold on a New York date, today by default and any past day. It is built only from fills that came from Schwab transactions (API sync) or imported Schwab and broker CSVs, never from the order list or a position snapshot. A sale imported by CSV and later synced from Schwab is one row: the sync skips a day an import already covers.

Each row (one per symbol and account) has shares, avgSellPrice, proceeds (shares times price less the sells' fees), fees, costBasis with basisSource (schwab_reported: Schwab's average cost a share from the last position snapshot before the sale, times the shares; lot_matched: the lots Investi matched from your fills; unknown) and basisNote, realized and realizedPct, heldDays (share-weighted over the lots closed) and heldDaysRange, eodClose (the 4:00 PM close from daily bars; before the close on the same day the live price, with live: true and closeSource: "live_quote"), eodVsSellPct (positive when the sale was above the close), leftOnTable ((close - average sell) x shares when positive, else 0), sharesStillHeld with heldSource (the freshest Schwab position, 0 when fully sold; the ledger when the account has no position after the sale), rebuy (a working buy on the symbol with its limit, current price and distancePct, or null) and fills (each with time, shares, price, fees, source). The answer also has totals (rows, shares, proceeds, costBasis over rows with a basis, realized, realizedPct, leftOnTable, fullySold), beforeClose (today, before the 4:00 PM close or 1:00 PM on early-close days), session (trading: false on weekends and NYSE holidays, closed: weekend or holiday, and previous and next: the adjacent trading days, next null when it would be after today), probesExcluded (sales of the probe size or fewer, see Probe lots) and definitions. A future date or a malformed one answers 400 bad_date.

Shell
curl -H "Authorization: Bearer $INVESTI_TOKEN" "https://your-investi/api/v1/sold?date=2026-11-04"
Shell
curl -H "Authorization: Bearer $INVESTI_TOKEN" "https://your-investi/api/v1/candidates?source=strict&limit=20"
curl -X POST -H "Authorization: Bearer $INVESTI_TOKEN" "https://your-investi/api/v1/candidates/ADBE/dismiss"

POST /api/v1/candidates/{ticker}/dismiss returns { ticker, refPrice }, where refPrice is the lower of the last price and the 52-week low; the name comes back, marked new, if it trades below it. It answers 404 for a ticker that is not among your candidates.

POST /api/v1/screens/import takes the CSV as a multipart file field, or as a text/csv request body with ?fileName=. The export type is detected from the header (Finviz: No., Ticker, Company; Schwab: Symbol; anything else with a Ticker column is other). It returns { id, source, rowCount, skipped, tickers }, 400 unrecognized_csv when no Symbol or Ticker column or no ticker rows are found, and 413 over 5 MB.

Risk#

Method Path Scope Summary
POST /simulate simulate What-if trade simulation (nothing is sent anywhere)
GET /stress simulate Standard stress scenarios on the portfolio. Each result has maintenanceRequirement, marginCall and forcedLiquidation (null until a maintenance rate is known); margin names the rate used and formulas states each formula
POST /stress simulate Custom stress scenario

Tax and options#

Method Path Scope Summary
GET /tax/summary portfolio:read Realized short-term and long-term by year (?year=YYYY), wash sales and the one-year date of every open lot. Estimates, not tax advice
GET /tax/harvest portfolio:read Loss-harvest candidates before Dec 31 with loss, term, wash-sale flag and estimated tax saving at your rates
GET /options/chain/{ticker} portfolio:read Call chain up to 90 days for a held ticker, from your Schwab market data. state is ok, not_connected, reconnect, rate_limited or unavailable
GET /options/covered-calls portfolio:read Covered-call screen for names with 100+ shares. Investi never places orders

Workspace#

Method Path Scope Summary
GET /watchlists research:read Watchlists
POST /watchlists workspace:write Add or remove a watchlist symbol
GET /alerts research:read Alerts, items with each alert's condition and triggered count, and recent trigger events (?evaluate=1 checks them first; ?ticker=, ?kind=)
POST /alerts/near-low workspace:write One near-52-week-low alert per ticker: { "tickers": [...], "pctWithinLow": 3 }. Tickers with a near-low alert already, repeats and unknown symbols are skipped and listed in skipped
POST /alerts workspace:write Create an alert. It is checked at once; the response is { id, ticker, triggered, message }
PATCH /alerts workspace:write Mark all triggered alert events read
DELETE /alerts/{id} workspace:write Delete an alert (404 when it is not yours)
GET /notes research:read Notes, pinned first then newest (?ticker=, ?kind=note|decision|thesis, ?since=2026-10-01, ?limit=)
POST /notes workspace:write Create a note
PATCH /notes/{id} workspace:write Pin or unpin a note (body { "pinned": true })
DELETE /notes/{id} workspace:write Delete a note
GET /policy research:read Current versioned strategy policy
PUT /policy workspace:write, session Save a new policy version
GET /settings portfolio:read User preferences
PUT /settings workspace:write, session Update preferences
POST /sample workspace:write, session Load the labeled sample portfolio
DELETE /sample workspace:write, session Remove the sample portfolio

Uploads, playbook, memory and chat#

Method Path Scope Summary
GET /uploads portfolio:read Your uploads, newest first, with limits (maxBytes, directAboveBytes, prefix) for the next upload
POST /uploads workspace:write Upload one file (multipart field file, up to 20 MB). It is checked and routed in the same request; returns { upload, duplicate } with upload.action saying what was done
POST /uploads/token workspace:write, session Token for a direct browser upload to Vercel Blob (files over 4 MB on Vercel), limited to your own folder
POST /uploads/complete workspace:write After a direct upload: { pathname, fileName }; checks and routes the stored file
GET /uploads/{id} portfolio:read One upload with its summary and the first 20,000 characters of extracted text
POST /uploads/{id} workspace:write { "action": "playbook" } reads a stored document as a strategy
DELETE /uploads/{id} workspace:write Delete the file and its memory lines (imported records stay)
GET /playbook research:read Current playbook (or ?version=N), its versions, and each proposed settings change against the current value
PUT /playbook workspace:write Save { rules, note? } as a new version
DELETE /playbook workspace:write Empty the playbook as a new version
POST /playbook/apply workspace:write, session { ruleId }: apply one proposed settings change through the strategy-rules checks
GET /memory portfolio:read Memory lines, newest first (?deleted=1 adds removed ones), and mode (local or supermemory)
POST /memory workspace:write { text } adds a line; { "action": "undo_clear", at } restores what a clear removed
DELETE /memory workspace:write Clear memory; returns { removed, at } for the undo
PATCH /memory/{id} workspace:write { text } edits a line
DELETE /memory/{id} workspace:write Remove a line (undoable)
POST /memory/{id} workspace:write { "action": "restore" } undoes a removal
GET /ai/chat research:read and portfolio:read The open thread for ?path= with its messages
POST /ai/chat research:read and portfolio:read Ask about a screen: { path, message, uploadIds?, stream? }. Returns { threadId, user, assistant, changes, proposals, missingUploads, model }, or newline-delimited JSON events with stream: true. Memory changes need workspace:write; without it they come back as proposals
DELETE /ai/chat research:read and portfolio:read Close the thread for ?path= (New thread)
POST /ai/chat/confirm workspace:write { messageId, index, decision } with decision apply or dismiss, for one proposed change on an answer

Access#

Method Path Scope Summary
GET /tokens session Personal access tokens and connected apps
POST /tokens session Create a personal access token (shown once)
DELETE /tokens/{id} session Revoke a token by the 12-character id shown in the list. Revoking an OAuth grant also revokes its access tokens.
GET /webhooks workspace:write, portfolio:read, research:read Digest webhooks (the secret is never returned again, only its last four characters)
POST /webhooks workspace:write, portfolio:read, research:read Register an HTTPS URL for the weekly digest; the signing secret is in this response only
DELETE /webhooks/{id} workspace:write Remove a webhook
POST /webhooks/{id}/test workspace:write, portfolio:read, research:read Send this week's digest now as a digest.test event

Note: Writes under workspace:write change only data inside Investi, such as notes, alerts, watchlists and recorded orders. Recording an order with POST /orders does not send it to Schwab or any other broker.