# REST API

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

Source: https://investi.live/docs/integrations/rest-api

## 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.

```bash
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](/api/v1/openapi.json). It needs no authentication, uses a relative server URL (`/api/v1`), and is cached for five minutes.

```bash
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:

```bash
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](/docs/app/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)](/docs/integrations/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:

```js
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.

### Session cookie

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](/docs/concepts/data-sources#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](/docs/strategy/rules#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).

```bash
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](/docs/app/orders) screen.

### Holdings

`GET /holdings` answers `{ asOf, rows, summary, pending, definitions }`, the same builder as the [Holdings](/docs/app/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.

```bash
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](/docs/app/settings) 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:

```js
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](/docs/support/security-privacy).

## Examples

### Manual check answers

```bash
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

```bash
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

```bash
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](/docs/app/research#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](/docs/app/research#the-lean-mark). |
| `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

```bash
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](/docs/getting-started/connect-schwab#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](/docs/concepts/canonical-numbers#the-pl-breakdown).
- 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

```bash
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](/docs/concepts/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

```bash
# 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

```bash
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](#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

```bash
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](/docs/concepts/ai-analysis#ask-follow-up-questions) and [Uploads, playbook and memory](/docs/concepts/uploads-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](/docs/concepts/ai-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](#holdings)) |
| GET | `/orders` | `portfolio:read` | Working orders with filters, a summary and pages (see [Open orders](#open-orders)) |
| GET | `/orders/sweep` | `portfolio:read` | Order risk sweep: a suggested action per working order (see [Order risk sweep](#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](/docs/getting-started/import-history#verdict-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](/docs/concepts/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](/docs/strategy/rules#probe-lots)) and `definitions`. A future date or a malformed one answers 400 `bad_date`.

```bash
curl -H "Authorization: Bearer $INVESTI_TOKEN" "https://your-investi/api/v1/sold?date=2026-11-04"
```

```bash
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.

## Related

- [Claude (MCP)](/docs/integrations/mcp) for the OAuth-based connector with the same data
- [Canonical numbers](/docs/concepts/canonical-numbers) for why every surface agrees
- [Ledger](/docs/concepts/ledger) for how round trips and realized P/L are computed
- [Security and privacy](/docs/support/security-privacy) for how tokens are stored
