# Axfolio Public API v1

The Axfolio Public API is a JSON-over-HTTPS REST API. You authenticate every request with a secret API key, and you receive structured JSON responses. There is nothing to install — if you can make an HTTPS request from your language of choice, you can use this API.

This page is the complete reference. Read it top to bottom the first time; after that, jump straight to the endpoint you need using the sidebar on the left.

---

## Getting started in 5 minutes

This is the fastest path from "no integration" to "first successful response."

### Step 1 — Generate an API key

1. Sign in to your Axfolio developer account at [https://axfolio.io/app](https://axfolio.io/app).
2. In the left sidebar, click **API Keys**.
3. Click **Generate new key** in the top right of the page.
4. A modal will appear with your full key. It looks like this:

   ```
   ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
   ```

5. Click the **eye icon** (👁) to reveal the full key, then **Copy to clipboard**.
6. Paste the key into your password manager (1Password, Bitwarden, etc.) or your project's `.env` file.

**This is the only time the full key will ever be shown.** If you close the modal without copying it, you must deactivate the key and generate a new one. Axfolio stores only a SHA-256 hash of your key, so we cannot recover or display it later.

### Step 2 — Make your first request

The simplest way to confirm everything works is to list your clients with `curl` from a terminal. `curl` is a command-line tool that ships with macOS, Linux, and Windows 10+.

Open a terminal and run this command, replacing `YOUR_API_KEY` with the key you copied in Step 1:

```bash
curl https://axfolio.io/api/v1/clients \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**What the parts of the command mean:**

- `curl` — the program that sends the HTTP request.
- `https://axfolio.io/api/v1/clients` — the URL of the endpoint you are calling.
- `-H "Authorization: Bearer YOUR_API_KEY"` — adds an HTTP header named `Authorization` with the value `Bearer YOUR_API_KEY`. This is how the server knows the request is from you. The literal word `Bearer` (with a capital B and a space after it) is required.

A fully filled-in command, with a real-looking key, would be:

```bash
curl https://axfolio.io/api/v1/clients \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

### Step 3 — Read the response

If your key is valid and your firm has at least one client, you will see something like this in your terminal:

```json
{
  "success": true,
  "data": [
    {
      "id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
      "name": "Sarah Chen",
      "created_at": "2025-11-15T18:22:04.123Z",
      "last_reviewed_at": "2026-05-18T14:30:00.000Z"
    }
  ]
}
```

If your firm has no clients yet, you'll see an empty list:

```json
{ "success": true, "data": [] }
```

If something is wrong — typically a bad key — you'll see an error response. See **Authentication errors** below for the most common ones.

### Step 4 — Move to your real code

Once `curl` works, switch to whatever language you're actually building in. Equivalent examples in JavaScript and Python are below; they do exactly the same thing.

**JavaScript (Node.js 18+ or any modern browser):**

```javascript
const response = await fetch('https://axfolio.io/api/v1/clients', {
  headers: {
    'Authorization': `Bearer ${process.env.AXFOLIO_API_KEY}`,
  },
});
const body = await response.json();
console.log(body.data);
```

**Python 3 (with the `requests` library — `pip install requests`):**

```python
import os
import requests

response = requests.get(
    'https://axfolio.io/api/v1/clients',
    headers={'Authorization': f'Bearer {os.environ["AXFOLIO_API_KEY"]}'},
)
response.raise_for_status()
print(response.json()['data'])
```

In both examples, the key is read from an environment variable named `AXFOLIO_API_KEY`. **Never hard-code the key into source code that you commit to version control.**

---

## Base URL

Every endpoint lives under this base URL:

```
https://axfolio.io/api/v1
```

So the full URL for the `clients` endpoint is `https://axfolio.io/api/v1/clients`, the full URL for `scan-book` is `https://axfolio.io/api/v1/scan-book`, and so on.

**HTTPS is required.** Requests made to `http://` (without the `s`) are rejected by the platform before they ever reach the API. Always use `https://`.

---

## Authentication

All endpoints require an API key. You provide it in the HTTP `Authorization` header on every request.

### The header

The header has exactly this format:

```
Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
```

Breaking it down:

| Part | Meaning |
|------|---------|
| `Authorization` | The name of the header. Always literally `Authorization`. |
| `Bearer` | The auth scheme. Always literally `Bearer`, capital B, followed by a single space. |
| `ak_live_…` | Your API key. Always starts with the prefix `ak_live_` followed by 32 hexadecimal characters (so 40 characters total). |

### Properties of an API key

- **Firm-scoped.** A key can only see data for clients that belong to your firm. There is no way to query another firm's data with your key.
- **One firm, many keys.** You may generate multiple keys (e.g. one for production, one for staging). Each key tracks its own usage metrics.
- **Revocable.** You can deactivate a key from the API Keys page at any time. Deactivated keys are rejected immediately on the next request. Reactivation is not supported — generate a new key instead.
- **Not rotatable in place.** There is no "rotate" action. The supported pattern is: generate a new key, deploy it to production, then deactivate the old one.

### Authentication errors

If your key is missing, malformed, or inactive, the API returns `HTTP 401 Unauthorized` with one of these messages in the `error` field:

```json
{ "success": false, "error": "Missing API key. Pass your key as: Authorization: Bearer ak_live_..." }
```

You sent a request with no `Authorization` header at all, or one that did not start with `Bearer `.

```json
{ "success": false, "error": "Empty Bearer token." }
```

The header was present but the token after `Bearer ` was empty (likely a templating bug — your `YOUR_API_KEY` placeholder wasn't substituted).

```json
{ "success": false, "error": "Invalid API key." }
```

The key does not exist. Either it's mistyped (check for spaces, line breaks, or missing characters), or it was never issued, or it was deleted.

```json
{ "success": false, "error": "API key is inactive. Contact your Axfolio admin." }
```

The key exists but has been deactivated. Generate a new key from the dashboard.

### Treating a key as compromised

If you suspect a key has leaked (committed to git, posted in a Slack message, captured in a log), deactivate it immediately from the API Keys page. The key will stop working on the next request. Then generate a new key.

---

## Rate limits

Each API key is limited to **100 requests per hour**, counted across all endpoints combined. The limit window is a rolling hour — not a calendar hour — so if you make 100 requests at 14:23, you'll be unblocked starting at 15:23.

### What happens when you hit the limit

The API returns `HTTP 429 Too Many Requests`:

```json
{
  "success": false,
  "error": "Rate limit exceeded. 100 requests/hour per API key."
}
```

The response also includes a `Retry-After` header. Its value is the number of seconds until you can make another request:

```
Retry-After: 1247
```

In the example above, `1247` means you can retry in 1,247 seconds (about 20 minutes).

### How to handle 429 in code

The correct response to a 429 is to wait, then retry. Read the `Retry-After` header rather than guessing.

**JavaScript:**

```javascript
async function callApi(url) {
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AXFOLIO_API_KEY}` } });
  if (res.status === 429) {
    const retryAfter = parseInt(res.headers.get('Retry-After') ?? '60', 10);
    await new Promise(r => setTimeout(r, retryAfter * 1000));
    return callApi(url);
  }
  return res.json();
}
```

**Python:**

```python
import time, requests, os

def call_api(url):
    res = requests.get(url, headers={'Authorization': f'Bearer {os.environ["AXFOLIO_API_KEY"]}'})
    if res.status_code == 429:
        time.sleep(int(res.headers.get('Retry-After', '60')))
        return call_api(url)
    res.raise_for_status()
    return res.json()
```

If you regularly need more than 100 requests per hour, contact support — we can raise the limit on a per-key basis.

---

## Response envelope

Every response — success or failure — uses the same JSON envelope.

### Successful responses

```json
{
  "success": true,
  "data": { ... }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | Always `true` for successful responses. |
| `data` | object or array | The actual payload. Shape depends on the endpoint. |

Some endpoints return additional top-level fields (e.g. `computing: true` on `risk-metrics`). These are documented per endpoint.

### Error responses

```json
{
  "success": false,
  "error": "Human-readable error description."
}
```

| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | Always `false` for error responses. |
| `error` | string | A human-readable explanation of what went wrong. Safe to display to your end users. |

The HTTP status code distinguishes the category of error. See the table in the next section.

---

## HTTP status codes

| Status | Meaning | Typical cause |
|--------|---------|---------------|
| `200 OK` | Success | The request completed and the response body contains your data. |
| `202 Accepted` | Computation in progress | Used only by `risk-metrics` when a portfolio exists but risk metrics haven't finished computing yet. Retry in a few seconds. |
| `400 Bad Request` | Bad input | A required parameter is missing, or a value is in the wrong format (e.g. `client_id` is not a UUID). |
| `401 Unauthorized` | Auth failed | Missing, malformed, invalid, or deactivated API key. See the four specific messages above. |
| `404 Not Found` | Resource missing | The requested client or portfolio doesn't exist, or doesn't belong to your firm. |
| `405 Method Not Allowed` | Wrong HTTP verb | You called a GET endpoint with POST, or vice-versa. The `error` field tells you which verb to use. |
| `429 Too Many Requests` | Rate limited | You exceeded 100 requests/hour. Wait the duration in `Retry-After` and retry. |
| `500 Internal Server Error` | Server bug | Something went wrong on our side. Retry once; if it persists, contact support. |
| `502 Bad Gateway` | Upstream failure | Used only by `generate-memo` when the Claude API is unreachable or returns malformed output. Retry after a few seconds. |

A correctly written client should:

1. Treat `200` as success.
2. Treat `202` as "retry in 3-5 seconds."
3. Treat `429` as "wait `Retry-After` seconds, then retry."
4. Treat `500` / `502` as "retry once with backoff, then surface the error."
5. Treat `400` / `401` / `404` / `405` as permanent failures — fix your code; retrying will not help.

---

## Endpoints

v1 currently exposes the endpoints listed below. They're grouped by feature; within a feature they're listed in the order you'll typically call them.

**Core data & analytics**

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/clients` | GET | List all clients in your firm. |
| `/api/v1/scan-book` | POST | Read the latest Book Review signals for your firm. |
| `/api/v1/risk-metrics` | GET | Get persisted risk metrics for one client's portfolio. |

**Portfolio analytics** — per-client read endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/performance` | GET | TWR + MWR + benchmark comparison for one client, single period. |
| `/api/v1/income` | GET | Expected next-12-month dividend income from the dividend cache. |
| `/api/v1/rmd-status` | GET | RMD tracker — required amount, distributed YTD, deadline, urgency. |
| `/api/v1/ips-status` | GET | Active IPS drift bands, open drift events, and sign-off status. |
| `/api/v1/recent-activity` | GET | Recent `activity_logs` entries for one client (max 50). |
| `/api/v1/alts-ingest-status` | GET | Alts document queue status and cash-flow verification counts. |

**Tax & RMD analytics** — per-client. Cached (1-hour TTL); cache expires on portfolio upload + Altruist sync.

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/tax-position` | GET | Current-year tax position: unrealized G/L, harvestable losses, realized YTD, cap-gains distributions, cash-vs-liability. |
| `/api/v1/tax-calendar` | GET | Upcoming tax events for one client over the next 90 days (RMD, estimated tax, TLH/Roth deadlines, K-1, cap-gains dist, wash-sale). |
| `/api/v1/tax-opportunities` | GET | Ranked tax opportunities: TLH, long-term-gain realization, charitable candidates, mutual-fund distribution avoidance. |

**Firm-wide analytics** — no `client_id` required

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/drift-events` | GET | All open IPS drift events across the firm, grouped by client. |
| `/api/v1/overdue-reviews` | GET | Clients past their annual (365-day) review window. |
| `/api/v1/rmd-dashboard` | GET | All RMD-eligible clients firm-wide, sorted by urgency. (cached 1h) |
| `/api/v1/firm-tax-calendar` | GET | Firm-wide upcoming tax deadlines + per-client breakdown + total harvestable loss. (cached 1h) |
| `/api/v1/firm-alts-ingest-summary` | GET | Firm-wide alts ingestion: pending verifications, needs-review, failed, oldest unverified date. |

**Meeting Prep** — the composed orchestrator. Cached bundle + fresh AI narrative.

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/meeting-prep` | GET | Assemble a meeting-ready game plan: 7-slice bundle (snapshot, signals, IPS, events, income, last review, documents) + 5-section AI narrative. Persists to `meeting_game_plans`. **30 req/hour** (Claude calls aren't free). |

**Memo Engine** — personalised monthly portfolio update letters

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/upload-voice-sample` | POST | Upload one past memo as a voice reference. |
| `/api/v1/voice-samples` | GET | List voice samples uploaded for the firm. |
| `/api/v1/voice-samples/:id` | DELETE | Remove one voice sample. |
| `/api/v1/generate-memo` | POST | Generate a personalised monthly memo for one client. |
| `/api/v1/generate-all-memos` | POST | Bulk-generate monthly memos for every client at the firm. |
| `/api/v1/list-memos` | GET | List all memos for the firm (filter by `month` and/or `status`). |
| `/api/v1/memos/:id` | GET, PATCH | Fetch full memo content; update status, content, or flag state. |

---

### GET /api/v1/clients

Lists every client in your firm.

**When to use it.** As the first call in any integration — you need the `id` field from this endpoint to call `risk-metrics` or `generate-memo`. Typical pattern: call `clients` once, cache the IDs, then call the per-client endpoints as needed.

**Authentication.** Bearer API key (required on every call — see Authentication above).

**Query parameters.** None.

**Request body.** None. Do not send a `Content-Type` header.

#### Example request

```bash
curl https://axfolio.io/api/v1/clients \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const res = await fetch('https://axfolio.io/api/v1/clients', {
  headers: { 'Authorization': `Bearer ${process.env.AXFOLIO_API_KEY}` },
});
const { data: clients } = await res.json();
```

```python
import os, requests
res = requests.get(
    'https://axfolio.io/api/v1/clients',
    headers={'Authorization': f'Bearer {os.environ["AXFOLIO_API_KEY"]}'},
)
res.raise_for_status()
clients = res.json()['data']
```

#### Example success response (200)

```json
{
  "success": true,
  "data": [
    {
      "id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
      "name": "Sarah Chen",
      "created_at": "2025-11-15T18:22:04.123Z",
      "last_reviewed_at": "2026-05-18T14:30:00.000Z"
    },
    {
      "id": "9a8b7c6d-5e4f-3210-fedc-ba9876543210",
      "name": "Marcus Williams",
      "created_at": "2025-12-01T10:00:00.000Z",
      "last_reviewed_at": null
    }
  ]
}
```

**Response field reference:**

| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID string | The client's unique identifier. Use this as the `client_id` parameter on other endpoints. |
| `name` | string | The client's full name as entered in Axfolio. |
| `created_at` | ISO 8601 timestamp (UTC) | When the client record was first created. |
| `last_reviewed_at` | ISO 8601 timestamp (UTC) or `null` | When the client was last reviewed in the Axfolio dashboard. `null` if the client has never been reviewed. |

Clients are returned sorted alphabetically by name. The response is not paginated — all clients are returned in a single call. If your firm exceeds 500 clients, contact support to discuss pagination.

---

### POST /api/v1/scan-book

Returns the signals from your firm's most recent Book Review run.

**When to use it.** When you want a programmatic feed of "which clients need attention." Common use cases: posting a daily digest to Slack, syncing flagged clients to a CRM, building a custom dashboard.

**Important — this is a read, not a recompute.** The Book Review computation is heavy (it touches every client in your firm and runs 19 separate signal checks). This endpoint reads the **persisted results** of the most recent run; it does not re-run the computation. To trigger a fresh run, go to the **Book Review** page in the Axfolio dashboard and click **Run Again**. The results will be persisted automatically and visible here on the next call.

**Authentication.** Bearer API key.

**Query parameters.** None.

**Request body.** None required, but you should send `Content-Type: application/json` because it's a POST.

#### Example request

```bash
curl -X POST https://axfolio.io/api/v1/scan-book \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \
  -H "Content-Type: application/json"
```

```javascript
const res = await fetch('https://axfolio.io/api/v1/scan-book', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.AXFOLIO_API_KEY}`,
    'Content-Type': 'application/json',
  },
});
const { data } = await res.json();
```

```python
import os, requests
res = requests.post(
    'https://axfolio.io/api/v1/scan-book',
    headers={'Authorization': f'Bearer {os.environ["AXFOLIO_API_KEY"]}'},
)
res.raise_for_status()
data = res.json()['data']
```

#### Example response — Book Review has been run (200)

```json
{
  "success": true,
  "data": {
    "last_run_at": "2026-05-22T09:00:00.000Z",
    "total_clients": 47,
    "flagged_clients": 6,
    "signals": [
      {
        "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
        "client_name": "Sarah Chen",
        "signal_type": "ipsDrift",
        "severity": "high",
        "suggested_action": "IPS band drift violation"
      },
      {
        "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
        "client_name": "Sarah Chen",
        "signal_type": "overdueReview",
        "severity": "medium",
        "suggested_action": "Client not reviewed recently"
      }
    ]
  }
}
```

#### Example response — Book Review has never been run (200)

```json
{
  "success": true,
  "data": {
    "last_run_at": null,
    "total_clients": 0,
    "flagged_clients": 0,
    "signals": [],
    "note": "No Book Review has been run for this firm yet."
  }
}
```

Run a Book Review from the dashboard and call this endpoint again.

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `last_run_at` | ISO 8601 timestamp (UTC) or `null` | When the most recent Book Review finished. `null` if never run. |
| `total_clients` | integer | How many clients were analyzed in the most recent run. |
| `flagged_clients` | integer | How many clients triggered at least one signal. |
| `signals` | array of objects | One entry per (client × triggered signal). A client with multiple triggered signals appears multiple times. |
| `signals[].client_id` | UUID string | The affected client's ID. |
| `signals[].client_name` | string | The affected client's name (denormalized so you don't have to join against `/clients`). |
| `signals[].signal_type` | string | The signal key. See the table below for the full list. |
| `signals[].severity` | string | Either `"high"` or `"medium"`. |
| `signals[].suggested_action` | string | A short human-readable label for the signal. |

#### Signal types

All 17 signal types that can appear in the `signal_type` field:

| `signal_type` | Severity | Description |
|---------------|----------|-------------|
| `ipsDrift` | high | Portfolio has drifted outside an IPS-documented allocation band. |
| `singleHoldingConcentration` | high | A single position exceeds the firm's concentration threshold. |
| `trueSectorConcentration` | high | One equity sector is over-concentrated. |
| `managerConcentration` | high | One fund manager holds an outsized share of the portfolio. |
| `etfOverlap` | high | Multiple ETFs hold significantly overlapping positions. |
| `highAltExposure` | high | Alternatives exposure exceeds typical thresholds. |
| `staleAltValuation` | high | An alternative holding's valuation date is stale. |
| `overdueReview` | medium | Client has not been reviewed recently. |
| `allocationDrift` | medium | Asset allocation has drifted from target. |
| `concentrationRisk` | medium | General single-holding concentration warning. |
| `cashDrag` | medium | Excessive uninvested cash. |
| `lowScore` | medium | Portfolio score is below the firm's threshold. |
| `tlhOpportunity` | medium | Tax-loss harvesting opportunity (Q4 + ≥$10K harvestable loss). |
| `ipsAnnualReview` | medium | IPS is approaching or past its 12-month review date. |
| `washSaleRisk` | medium | Possible IRC §1091 wash sale detected in recent transactions. |
| `rmdDueSoon` | medium | Required Minimum Distribution is approaching. |
| `altSnapshotStale` | medium | An alternative holding's snapshot is older than 90 days. |

---

### GET /api/v1/risk-metrics

Returns the persisted portfolio analytics and risk metrics for a single client.

**When to use it.** When you need quantitative risk numbers (volatility, beta, Sharpe, drawdown) or portfolio scores for one client. Typical use: enriching CRM records, building risk reports.

**Source of truth.** The risk metrics are computed server-side whenever a portfolio snapshot is saved (after a CSV upload or an Altruist sync). You don't have to do anything to trigger the computation — but the computation takes a few seconds. If you call this endpoint immediately after uploading a portfolio, you may get a `202 Accepted` response while the metrics finish computing. Retry in 3-5 seconds.

Portfolio score metrics (`score_overall`, `score_diversification`, etc.) come from a separate table and are usually available immediately after upload.

**Authentication.** Bearer API key.

**Query parameters:**

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |

**Request body.** None.

#### Example request

```bash
curl "https://axfolio.io/api/v1/risk-metrics?client_id=3c4d5e6f-7890-abcd-ef01-234567890abc" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

Note the double quotes around the URL — `&` characters in the query string are interpreted by your shell otherwise. (There's only one parameter here, so it doesn't matter, but it's a good habit.)

```javascript
const clientId = '3c4d5e6f-7890-abcd-ef01-234567890abc';
const url = `https://axfolio.io/api/v1/risk-metrics?client_id=${clientId}`;
const res = await fetch(url, {
  headers: { 'Authorization': `Bearer ${process.env.AXFOLIO_API_KEY}` },
});
if (res.status === 202) {
  // Metrics still computing — retry in a few seconds.
} else {
  const { data } = await res.json();
}
```

```python
import os, time, requests

def get_risk_metrics(client_id, max_retries=3):
    url = f'https://axfolio.io/api/v1/risk-metrics?client_id={client_id}'
    headers = {'Authorization': f'Bearer {os.environ["AXFOLIO_API_KEY"]}'}
    for attempt in range(max_retries):
        res = requests.get(url, headers=headers)
        if res.status_code == 202:
            time.sleep(5)
            continue
        res.raise_for_status()
        return res.json()['data']
    raise RuntimeError('Risk metrics still computing after retries')
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
    "client_name": "Sarah Chen",
    "portfolio_id": "aabbccdd-eeff-0011-2233-445566778899",
    "portfolio_name": "Sarah Chen - Retirement",
    "as_of_date": "2026-05-15",
    "volatility": 0.138,
    "beta": 0.94,
    "sharpe_ratio": 1.21,
    "max_drawdown": -0.187,
    "sortino_ratio": 1.68,
    "information_ratio": 0.31,
    "tracking_error": 0.044,
    "var95": -0.021,
    "cvar95": -0.029,
    "benchmark": "S&P 500",
    "observations_used": 1247,
    "score_overall": 74,
    "score_diversification": 82,
    "score_concentration": 68,
    "score_risk_alignment": 71,
    "model_label": "Moderate Growth",
    "computed_at": "2026-05-20T14:22:00.000Z"
  }
}
```

#### Example "computing" response (202)

```json
{
  "success": true,
  "computing": true,
  "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
  "client_name": "Sarah Chen",
  "portfolio_id": "aabbccdd-eeff-0011-2233-445566778899",
  "portfolio_name": "Sarah Chen - Retirement",
  "as_of_date": "2026-05-15"
}
```

Treat `computing: true` as "the portfolio exists, but the risk metrics row hasn't been written yet." Retry in a few seconds.

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `client_id` | UUID string | The client you queried. |
| `client_name` | string | Convenience copy of the client's name. |
| `portfolio_id` | UUID string | The most recent portfolio snapshot for this client. |
| `portfolio_name` | string | The portfolio's display name. |
| `as_of_date` | YYYY-MM-DD string | The portfolio's as-of date. |
| `volatility` | decimal | Annualized standard deviation of daily returns. `0.138` = 13.8%. |
| `beta` | decimal | Sensitivity to the benchmark. `1.0` = moves with the benchmark; `0.9` = moves 90% as much. Unitless. |
| `sharpe_ratio` | decimal | Excess return per unit of total risk. Unitless. Higher is better. |
| `max_drawdown` | decimal (negative) | Largest peak-to-trough drawdown. `-0.187` = 18.7% drawdown. |
| `sortino_ratio` | decimal | Excess return per unit of downside risk. Unitless. Higher is better. |
| `information_ratio` | decimal | Excess return over benchmark, per unit of tracking error. Unitless. |
| `tracking_error` | decimal | Annualized stdev of (portfolio − benchmark) returns. `0.044` = 4.4%. |
| `var95` | decimal (negative) | One-day 95% Value at Risk. `-0.021` = expect a loss of at least 2.1% on the worst 5% of days. |
| `cvar95` | decimal (negative) | One-day 95% Conditional VaR (expected loss in the worst 5% of days). |
| `benchmark` | string | Label of the benchmark used for beta / tracking error (e.g. `"S&P 500"`). |
| `observations_used` | integer | Number of daily observations the computation was based on. |
| `score_overall` | integer (0-100) | Composite portfolio score. |
| `score_diversification` | integer (0-100) | Diversification sub-score. |
| `score_concentration` | integer (0-100) | Concentration sub-score. |
| `score_risk_alignment` | integer (0-100) | Alignment with the client's selected risk model. |
| `model_label` | string | Name of the model the portfolio is aligned to (e.g. `"Moderate Growth"`). |
| `computed_at` | ISO 8601 timestamp (UTC) | When the metrics were last computed. |

#### Error responses for this endpoint

| Status | `error` message | What to do |
|--------|-----------------|------------|
| 400 | `Missing required query param: client_id` | Add the parameter. |
| 400 | `client_id must be a valid UUID.` | The value isn't in `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` format. |
| 404 | `Client not found or does not belong to this firm.` | The ID doesn't exist under your firm. Re-check the `/clients` list. |
| 404 | `No portfolio has been uploaded for this client yet.` | Upload a portfolio for this client (CSV or Altruist sync). |

---

### POST /api/v1/upload-voice-sample

Uploads one past memo as a voice reference. The Memo Engine uses up to the 10 most recently uploaded samples as style references when it generates new memos. Upload at least two or three before calling `generate-memo` — more samples produce more on-voice letters.

```json
{
  "filename": "Q1 2026 client letter",
  "content":  "Dear Sarah, Markets in Q1 felt..."
}
```

Limits: `content` is plain text, max 200KB; `filename` max 256 chars.

Response: `201 Created` with `{ success, data: { id, filename, uploaded_at } }`.

```bash
curl -X POST https://axfolio.io/api/v1/upload-voice-sample \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{"filename":"Q1 2026 letter","content":"Dear Sarah, ..."}'
```

---

### GET /api/v1/voice-samples

Lists every voice sample uploaded for your firm. The `content` field is OMITTED here (samples can be large). To remove a sample, call `DELETE /api/v1/voice-samples/:id`.

Response: `{ success, data: [{ id, filename, uploaded_at }] }`.

---

### DELETE /api/v1/voice-samples/:id

Removes one voice sample. Past memos already generated keep their content untouched — future generation simply stops using this sample.

Response: `200` with `{ success, data: { id, deleted: true } }`. Returns `404` if the id is not in your firm.

---

### POST /api/v1/generate-memo

Generates a personalised monthly portfolio update letter for ONE client × ONE month. The letter is written in the advisor's voice (taught from the uploaded voice samples) and references the client's actual holdings.

**When to use it.** Mid-cycle generation: a client missed last month's letter, the advisor edited a voice sample and wants to regenerate one client, or a single new client onboarded mid-month. Bulk monthly cycles use `generate-all-memos` instead.

**Idempotency.** Each call upserts on `(firm_id, client_id, month)`. Re-running for the same client/month overwrites the existing draft — useful when you've added more voice samples and want fresher output.

**What goes into the memo.** The endpoint reads the client's most recently uploaded portfolio (top 25 positions by market value), the firm's most recent voice samples, and the month you pass. It then asks Claude (`claude-sonnet-4-20250514`, `max_tokens=1000`) to write a short personal letter. Claude is instructed to match the voice samples exactly and to use only the data provided — no forward-looking predictions, no tax advice, no trade instructions.

```json
{
  "client_id": "5a4f9a3a-...",
  "month":     "2026-05-01"
}
```

`month` must be the first day of the month, formatted `YYYY-MM-01`.

Response: `{ success, data: { id, client_id, client_name, month, status: "draft", content, flagged, generated_at } }`.

Calls Claude — expect 10–30 seconds of latency. Status is always `draft` immediately after generation; the advisor (or another system) flips it to `approved` then `sent` after review.

```bash
curl -X POST https://axfolio.io/api/v1/generate-memo \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{"client_id":"5a4f9a3a-...","month":"2026-05-01"}'
```

---

### POST /api/v1/generate-all-memos

Bulk-generates monthly memos for EVERY client at the firm in one call. Use this for your monthly memo cycle. Runs sequentially across clients — one Claude call per client — so a 100-client firm should expect ~10–25 minutes of total wall-clock time. Per-client failures are recorded but do not abort the run.

```json
{ "month": "2026-05-01" }
```

Rate limit: **10 bulk runs/hour per API key** (much tighter than the 100/hour on most other endpoints — each call fans out into N Claude requests).

Response: `{ success, data: { generated, failed, total, month, failures: [{ client_id, client_name, error }] } }`.

```bash
curl -X POST https://axfolio.io/api/v1/generate-all-memos \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{"month":"2026-05-01"}'
```

---

### GET /api/v1/list-memos

Lists every memo for your firm. The `content` field is OMITTED (memos can be long; fetch content via `GET /api/v1/memos/:id`). Optional filters:

| Query param | Format | Effect |
|---|---|---|
| `month` | `YYYY-MM-01` | Only memos for that month. |
| `status` | `draft \| approved \| flagged \| sent` | Only memos with that status. |

Response: `{ success, data: [{ id, client_id, month, status, flagged, flagged_reason, generated_at, approved_at, sent_at, created_at, updated_at }] }`.

```bash
curl "https://axfolio.io/api/v1/list-memos?month=2026-05-01&status=draft" \
  -H "Authorization: Bearer ak_live_..."
```

---

### GET /api/v1/memos/:id

Fetches one memo INCLUDING the full `content` body.

Response: `{ success, data: { id, client_id, month, status, content, flagged, flagged_reason, generated_at, approved_at, sent_at, created_at, updated_at } }`.

---

### PATCH /api/v1/memos/:id

Updates one memo. Use this to approve, flag, edit content, or mark sent. Any subset of these fields:

```json
{
  "status":         "approved",
  "content":        "Dear Sarah, ...",
  "flagged":        true,
  "flagged_reason": "Wrong holding mentioned"
}
```

Side-effects:
- Setting `status` to `approved` automatically sets `approved_at`.
- Setting `status` to `sent` automatically sets `sent_at`.
- Setting `flagged` to `false` automatically clears `flagged_reason`.

Response: `{ success, data: { ...updated row... } }`.

---

### Memo status lifecycle

```
generated  →  draft  →  approved  →  sent
                │
                └──►  flagged  →  draft (after revision)  →  approved  →  sent
```

The `sent_at` timestamp is informational only — Axfolio does NOT send the email. Most API customers send via their own email infrastructure and `PATCH` the memo to `status: "sent"` once delivery succeeds on their side.

### Memo Engine error codes

| HTTP | Reason | What to do |
|---|---|---|
| 400 | `month must be a YYYY-MM-DD date string (first day of the month).` | Reformat your `month` field. |
| 404 | `Client not found or does not belong to this firm.` / `Memo not found.` | The id you passed isn't in your firm. |
| 404 | `No portfolio data found for this client. Upload a portfolio first.` | Generation needs holdings. Connect Altruist or upload a CSV first. |
| 429 | `Rate limit exceeded. 10 bulk runs/hour per API key.` | Bulk-generation is throttled because each call fans out into many Claude calls. Wait and retry. |
| 502 | `Claude API returned …` | Transient. Retry. |

---

## Best practices

### Store keys in environment variables

Never commit an API key to source control. The standard pattern in most languages is to read the key from an environment variable named `AXFOLIO_API_KEY`. For local development, load it from a `.env` file that is listed in `.gitignore`.

### Handle 202, 429, and 5xx with retries

Your client should automatically retry these three categories:

- `202 Accepted` (risk-metrics computing) — retry in 3-5 seconds, up to 3 times.
- `429 Too Many Requests` — sleep for the duration in `Retry-After`, then retry.
- `500` / `502` — exponential backoff, up to 3 times.

Do not retry `400` / `401` / `404` / `405` — these are bugs in your code that retrying won't fix.

### Cache responses when you can

`/clients` returns the same data on every call unless you've added or removed clients. Cache it for at least a minute to avoid burning your rate limit. `risk-metrics` only changes when a new portfolio is uploaded or the daily metrics recompute runs.

### Don't poll generate-memo

`generate-memo` is the most expensive endpoint (10-30s of compute, plus AI cost). Generate a memo when an advisor needs it; never poll it on a timer.

### Monitor your usage

Each key has a `monthly_call_count` visible on the API Keys page. If you see unexpected traffic, deactivate the key, investigate, and re-issue.

---

## Frequently asked questions

**Can I query data for clients in another firm?**
No. Every key is scoped to one firm. Cross-firm access is not supported and never will be — it would be a compliance violation.

**Can I write data through the API?**
Yes — for memos. The Memo Engine endpoints (`upload-voice-sample`, `generate-memo`, `generate-all-memos`, `PATCH /memos/:id`, `DELETE /voice-samples/:id`) all write. The core data endpoints (`clients`, `scan-book`, `risk-metrics`) remain read-only in v1.

**Can I upload a portfolio CSV through the API?**
Not in v1. Use the Axfolio dashboard for now.

**Why is `last_reviewed_at` sometimes `null`?**
A client is only marked "reviewed" when an advisor opens their Book Review and explicitly marks the client as reviewed. Clients who exist in the dashboard but haven't been reviewed yet will have `null` here.

**How long is an API key valid?**
Forever, until you deactivate it. There is no automatic expiry.

**Can I share a key across multiple environments?**
You can, but you shouldn't. Generate one key per environment (dev/staging/prod) so you can deactivate one without breaking the others.

**Is there a sandbox environment?**
Not currently. The API runs against your live firm data. We recommend creating a "test" client in the dashboard for integration testing.

**Do you have a Python or JavaScript SDK?**
Not yet. The API is simple enough that idiomatic `fetch` / `requests` calls cover everything. SDKs are on the roadmap.

---

---

### GET /api/v1/performance

Returns time-weighted return (TWR), money-weighted return (MWR), and optional benchmark comparison for a single client over a specified period.

**When to use it.** When you need period return figures for a client — to enrich a CRM, build a custom report, or compare against a benchmark. TWR uses Modified Dietz daily chain-linking; MWR uses bisection IRR. Returns are gross of fees.

**Source of truth.** `portfolio_value_history` rows written by the Altruist daily cron or by CSV upload events. The more daily snapshots exist, the more precise the TWR. If the client has fewer than two snapshots in the requested window you get a `202 not_yet_computed` response.

**Authentication.** Bearer API key.

**Query parameters:**

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |
| `period` | no | string | `1M` / `3M` / `YTD` / `1Y` / `3Y` / `5Y` / `ITD`. Default: `YTD`. |
| `benchmark` | no | string | Any ticker accepted by Massive (e.g. `SPY`, `AGG`, `QQQ`). Default: `SPY`. Benchmark fetch is best-effort — omitted from the response if it fails. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/performance?client_id=3c4d5e6f-7890-abcd-ef01-234567890abc&period=1Y&benchmark=SPY" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const params = new URLSearchParams({ client_id: '3c4d5e6f-...', period: '1Y', benchmark: 'SPY' });
const res = await fetch(`https://axfolio.io/api/v1/performance?${params}`, {
  headers: { Authorization: 'Bearer ak_live_...' },
});
const { data } = await res.json();
console.log(data.portfolio.twr_pct, data.benchmark?.twr_pct);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/performance',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '3c4d5e6f-...', 'period': '1Y', 'benchmark': 'SPY'},
)
data = r.json()['data']
print(data['portfolio']['twr_pct'], data.get('benchmark', {}).get('twr_pct'))
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
    "client_name": "Sarah Chen",
    "period": "1Y",
    "window": { "start_date": "2025-05-24", "end_date": "2026-05-24", "days": 365 },
    "portfolio": {
      "twr_pct": 11.42,
      "annualized_pct": 11.42,
      "days": 365,
      "observations": 249,
      "data_complete": true,
      "simple_return": false
    },
    "mwr": { "annualized_pct": 10.88, "converged": true },
    "benchmark": { "ticker": "SPY", "twr_pct": 9.17, "annualized_pct": 9.17 },
    "alpha_pct": 2.25,
    "cashflow_count": 4,
    "portfolios_total": 2,
    "as_of_date": "2026-05-24",
    "inception_date": "2023-11-01",
    "disclosure": "Returns are gross of fees. TWR uses Modified Dietz daily chain-linking. MWR uses bisection IRR. Not GIPS-verified. simple_return=true means no Altruist cash-flow data is available; the figure is (end/start − 1)."
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `period` | string | The period you requested. |
| `window.start_date` / `end_date` | YYYY-MM-DD | Calendar window used. |
| `portfolio.twr_pct` | decimal (percentage) | Time-weighted return for the period. `11.42` = 11.42%. |
| `portfolio.annualized_pct` | decimal (percentage) | CAGR. Only set when `days > 365`; `null` for shorter periods. |
| `portfolio.simple_return` | boolean | `true` when no Altruist transaction data is available — TWR falls back to `(end/start − 1)`. |
| `portfolio.data_complete` | boolean | `false` when gaps were detected in the daily snapshot series. |
| `mwr.annualized_pct` | decimal (percentage) | Money-weighted return (IRR), annualized. |
| `mwr.converged` | boolean | `false` is rare but means the IRR bisection didn't bracket — treat as `null`. |
| `benchmark.twr_pct` | decimal (percentage) | Benchmark TWR for the same window. `null` if Massive fetch failed. |
| `alpha_pct` | decimal (percentage) | `portfolio.twr_pct − benchmark.twr_pct`. `null` if benchmark unavailable. |
| `disclosure` | string | Required disclosure to show to clients or in reports. |

#### Error responses

| Status | `error` message | What to do |
|--------|-----------------|------------|
| 400 | `Missing required query param: client_id` | Add the parameter. |
| 400 | `client_id must be a valid UUID.` | Fix the format. |
| 404 | `Client not found or does not belong to this firm.` | Re-check the `/clients` list. |
| 404 | `No portfolio has been uploaded for this client yet.` | Upload a portfolio first. |
| 202 | `computing: true` | Not enough snapshots yet — retry later or upload more history. |

---

### GET /api/v1/income

Returns the expected next-12-month (NTM) dividend income for a single client, derived from their current portfolio positions and the cached `dividend_history` table.

**When to use it.** When you need a projected annual income figure for a client — for cash-flow planning, reporting, or client-facing income summaries.

**Source of truth.** Position data from `portfolio_positions` (CSV uploads) or `altruist_positions` (Altruist sync). Dividend rates from `dividend_history` (cached, updated weekly). The endpoint reads from cache only — it does not trigger a fresh Massive API call. If the cache is stale for a ticker, run a portfolio refresh in the Axfolio dashboard.

**Authentication.** Bearer API key.

**Query parameters:**

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/income?client_id=3c4d5e6f-7890-abcd-ef01-234567890abc" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/income?client_id=3c4d5e6f-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
console.log(data.total_expected_next_12_months_usd, data.portfolio_yield_pct);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/income',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '3c4d5e6f-...'},
)
data = r.json()['data']
print(data['total_expected_next_12_months_usd'], data['portfolio_yield_pct'])
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
    "client_name": "Sarah Chen",
    "total_expected_next_12_months_usd": 8420,
    "portfolio_yield_pct": 2.18,
    "producing_count": 12,
    "considered_count": 18,
    "total_market_value": 412000,
    "eligible_market_value": 386000,
    "top_contributors": [
      { "ticker": "VYM", "shares": 250, "annual_per_share": 3.42, "expected_annual": 855, "yield_pct": 3.1, "frequency": 4 }
    ],
    "recent_payments": [...],
    "missing_dividend_data_tickers": ["BRK.B", "GOOG"],
    "portfolios_aggregated": 2,
    "note": "Projection = recurring cash dividends (TTM rate × shares per holding). ..."
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `total_expected_next_12_months_usd` | integer | Projected annual income in USD, rounded to the nearest dollar. |
| `portfolio_yield_pct` | decimal | Projected yield as a percentage of eligible market value. |
| `producing_count` | integer | Number of positions that have a matching dividend rate in the cache. |
| `considered_count` | integer | Total positions evaluated. `producing_count / considered_count` = coverage ratio. |
| `top_contributors` | array | Top 5 income-producing positions by expected annual income. |
| `recent_payments.payment_total` | integer | Estimated payment total: `cash_amount × shares`, rounded to nearest dollar. |
| `missing_dividend_data_tickers` | array | Tickers excluded from the projection due to missing or irregular dividends. |

#### Error responses

| Status | `error` message | What to do |
|--------|-----------------|------------|
| 400 | `Missing required query param: client_id` | Add the parameter. |
| 404 | `Client not found or does not belong to this firm.` | Re-check the `/clients` list. |
| 404 | `No portfolio has been uploaded for this client yet.` | Upload a portfolio first. |
| 404 | `No positions with valid tickers found for this client.` | The most recent portfolio snapshot has no valid position rows. |

---

### GET /api/v1/rmd-status

Returns the Required Minimum Distribution tracker for a single client: required amount, amount distributed YTD, remaining balance, deadline, and urgency level.

**When to use it.** When you need to surface RMD compliance status to an advisor or in a reporting dashboard. All figures are deterministic — computed from the IRS Uniform Lifetime Table (2022 revision, SECURE Act 2.0 start ages 73/75), the client's date of birth, prior year-end portfolio balance, and recorded distributions.

**Source of truth.** `clients.date_of_birth`, `portfolio_value_history` (for the prior December 31 balance), and `rmd_executions` (distributions recorded in the Compliance sub-tab by the advisor). Axfolio has no distribution authority — the advisor executes through the custodian.

**Authentication.** Bearer API key.

**Query parameters:**

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/rmd-status?client_id=3c4d5e6f-7890-abcd-ef01-234567890abc" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/rmd-status?client_id=3c4d5e6f-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
if (data.rmd_required_this_year) {
  console.log(`RMD: ${data.remaining_usd} remaining by ${data.deadline} (${data.urgency})`);
}
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/rmd-status',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '3c4d5e6f-...'},
)
data = r.json()['data']
if data.get('rmd_required_this_year'):
    print(f"RMD: {data['remaining_usd']} remaining by {data['deadline']} ({data['urgency']})")
```

#### Example success response (200) — RMD required

```json
{
  "success": true,
  "data": {
    "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
    "client_name": "Sarah Chen",
    "tax_year": 2026,
    "has_dob": true,
    "rmd_required_this_year": true,
    "client_age": 75,
    "rmd_age": 75,
    "is_first_rmd_year": true,
    "prior_year_end_balance": 950000,
    "balance_available": true,
    "distribution_period": 24.6,
    "required_amount_usd": 38617.89,
    "distributed_ytd_usd": 10000,
    "remaining_usd": 28617.89,
    "deadline": "2027-04-01",
    "days_until_deadline": 312,
    "status": "in_progress",
    "urgency": "info",
    "distribution_history": [
      { "date": "2026-03-15", "account": "IRA at Altruist", "amount_usd": 10000 }
    ],
    "note": "Deterministic — not tax advice. ..."
  }
}
```

#### Example response — no date of birth

```json
{
  "success": true,
  "data": {
    "client_id": "...",
    "client_name": "Sarah Chen",
    "tax_year": 2026,
    "has_dob": false,
    "note": "No date of birth on file for this client. Add it in Axfolio to enable RMD tracking."
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `rmd_required_this_year` | boolean | `false` for clients who haven't reached their RMD age yet. |
| `urgency` | string | `complete` / `info` / `attention` / `warning` / `critical` / `overdue`. |
| `status` | string | `in_progress` / `complete` / `overdue` / `balance_required`. |
| `balance_available` | boolean | `false` means no prior December 31 portfolio snapshot exists — `required_amount_usd` will be `null`. |
| `distribution_period` | decimal | IRS Uniform Lifetime Table divisor for the client's age (e.g. `24.6` at age 75). |

#### Error responses

| Status | `error` message | What to do |
|--------|-----------------|------------|
| 400 | `Missing required query param: client_id` | Add the parameter. |
| 404 | `Client not found or does not belong to this firm.` | Re-check the `/clients` list. |

---

### GET /api/v1/ips-status

Returns the active Investment Policy Statement (IPS) drift bands, open (unresolved) drift events, and supervisor sign-off status for a single client.

**When to use it.** When you need to surface IPS compliance state in an integration — checking whether a client has open drift alerts, whether those alerts have been signed off, or simply reading the current target allocations and band constraints.

**Source of truth.** `ips_documents` (active IPS), `ips_drift_events` (append-only compliance audit trail), and `ips_supervisory_reviews` (supervisor sign-off records). Drift detection runs whenever a portfolio snapshot is saved or synced via Altruist.

**Authentication.** Bearer API key.

**Query parameters:**

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/ips-status?client_id=3c4d5e6f-7890-abcd-ef01-234567890abc" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/ips-status?client_id=3c4d5e6f-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
console.log(`${data.open_drift_events_count} open drift events, ${data.pending_signoffs_count} pending sign-offs`);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/ips-status',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '3c4d5e6f-...'},
)
data = r.json()['data']
print(f"{data['open_drift_events_count']} open, {data['pending_signoffs_count']} pending sign-off")
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
    "client_name": "Sarah Chen",
    "ips": {
      "id": "aabb...",
      "effective_date": "2025-01-15",
      "target_allocations_pct": { "Equity": 60, "Fixed Income": 30, "Cash": 10 },
      "drift_bands_pct": { "Equity": 5, "Fixed Income": 5, "Cash": 3 },
      "alt_limit_pct": 15,
      "single_position_limit_pct": 10,
      "prohibited_tickers": [],
      "prohibited_sectors": [],
      "notes": null
    },
    "open_drift_events_count": 1,
    "open_drift_events": [
      {
        "id": "ccdd...",
        "detected_at": "2026-05-20T09:00:00Z",
        "asset_class": "Equity",
        "current_pct": 67.2,
        "target_pct": 60,
        "band_lower": 55,
        "band_upper": 65,
        "drift_pp": 7.2,
        "severity": "high"
      }
    ],
    "pending_signoffs_count": 0,
    "pending_signoffs": [],
    "recent_signoffs": []
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `ips.target_allocations_pct` | object | Target allocation per asset class, in percentage points. |
| `ips.drift_bands_pct` | object | Allowed deviation per asset class before a drift event fires. |
| `open_drift_events` | array | Events where the portfolio has drifted outside a band and the advisor hasn't resolved them yet. |
| `open_drift_events[].drift_pp` | decimal | Percentage-point deviation from the target. |
| `open_drift_events[].severity` | string | `high` / `medium` / `low`. |
| `pending_signoffs_count` | integer | Resolved events that have not yet received an admin supervisor sign-off (part of the SEC 206(4)-7 chain). |
| `recent_signoffs` | array | Most recent supervisor sign-off records (up to 20). |
| `recent_signoffs[].outcome` | string | `approved` / `escalated` / `requires_rebalance`. |

#### Error responses

| Status | `error` message | What to do |
|--------|-----------------|------------|
| 400 | `Missing required query param: client_id` | Add the parameter. |
| 404 | `Client not found or does not belong to this firm.` | Re-check the `/clients` list. |
| 404 | `No active IPS found for this client.` | Create an IPS for this client in the Axfolio dashboard first. |

---

## MCP server

Every v1 endpoint above is also exposed as an **MCP tool** at `/api/mcp`. This lets Claude Desktop, Cursor, Zed, and any other MCP-compatible client call Axfolio directly — no SDK, no custom integration code.

Your existing v1 API key is the bearer token. No new auth to set up.

### Connect Claude Desktop

Add this entry to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows):

```json
{
  "mcpServers": {
    "axfolio": {
      "type": "http",
      "url": "https://axfolio.io/api/mcp",
      "headers": {
        "Authorization": "Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
      }
    }
  }
}
```

Restart Claude Desktop. You should see all 25 Axfolio tools appear in the tool menu.

### Tool catalog

The MCP server exposes 25 tools wrapping every v1 endpoint:

**Read tools (22)** — annotated `readOnlyHint: true, idempotentHint: true`:

`list_clients` · `get_client` · `get_risk_metrics` · `get_performance` · `get_income` · `get_rmd_status` · `get_ips_status` · `get_tax_position` · `get_tax_calendar` · `get_tax_opportunities` · `get_recent_activity` · `get_alts_ingest_status` · `list_memos` · `get_memo` · `scan_book` · `list_voice_samples` · `get_drift_events` · `get_overdue_reviews` · `get_rmd_dashboard` · `get_firm_tax_calendar` · `get_firm_alts_ingest_summary` · `get_meeting_prep`

**Write tools (3)** — annotated `destructiveHint: false` (safe writes); Claude Desktop will still prompt for confirmation:

`generate_memo` · `update_memo` · `upload_voice_sample`

`get_client` is a convenience wrapper — it calls `list_clients` internally and returns just the matching record. There's no `GET /api/v1/clients/:id` HTTP endpoint behind it.

### Rate limits

The MCP transport layer has a coarse safety net of **600 requests/hour** per API key. **Per-tool limits still apply on top** — calling the `get_meeting_prep` tool 35 times in an hour will trip `v1-meeting-prep`'s 30/hour ceiling exactly as if you called the HTTP endpoint directly. The expensive ones (`get_meeting_prep`, `generate_memo`, `scan_book` with `rerun=true`) are the bottleneck; cheap reads stay under the transport ceiling.

### Protocol details

| | |
|---|---|
| Transport | Streamable HTTP (JSON-RPC 2.0 over `POST /api/mcp`) |
| Protocol versions supported | `2025-06-18` (default), `2025-03-26`, `2024-11-05` |
| Auth | `Authorization: Bearer ak_live_...` — the same key you generate at `/app#developer/keys` |
| Server-initiated messages | None — `GET /api/mcp` returns 405 with a JSON-RPC error explaining why |
| Batched requests | Accepted — the response is an array of results in matching order |

### Error envelopes

- **Auth failure** → JSON-RPC error response with code `-32600` and `message: "Authentication failed: ..."`. Never a raw HTTP 401.
- **Unknown tool** → JSON-RPC error code `-32602`, `message: "Unknown tool: <name>"`.
- **Tool execution failed** (e.g. v1 endpoint returned `success: false`, network timeout) → JSON-RPC `result` with `isError: true` and the upstream error in `content[0].text`. Per MCP convention, tool errors are not protocol errors.
- **Transport rate limit exceeded** → JSON-RPC error code `-32603`, plus an `Retry-After` HTTP header.

### Example session (raw JSON-RPC)

```bash
# Initialize
curl -X POST https://axfolio.io/api/mcp \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}'

# List available tools
curl -X POST https://axfolio.io/api/mcp \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# Call the meeting-prep tool
curl -X POST https://axfolio.io/api/mcp \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_meeting_prep","arguments":{"client_id":"8b1c2d3e-..."}}}'
```

### Telemetry

Every tool call emits one structured line to the Vercel function log:

```
[mcp] firm=<firm_id> tool=<tool_name> ms=<elapsed> status=ok|error err=<message>
```

If you're investigating performance or error patterns, grep the function logs by `[mcp]` then filter by `tool=` or `status=error`.

---

## Changelog

| Version | Date | Notes |
|---------|------|-------|
| v1.0 | 2026-05-22 | Initial release — `clients`, `scan-book`, `risk-metrics`, `generate-memo`. |
| v1.0.1 | 2026-05-22 | `risk-metrics`: advanced metrics persisted from Advanced Metrics tab; full vol/beta/sharpe/drawdown available. |
| v1.1.0 | 2026-05-22 | Documentation rewrite: explicit step-by-step quickstart, multi-language examples for every endpoint, explicit error reference, FAQ. `risk-metrics` 202 `computing` response documented. No behavior change. |
| v1.2.0 | 2026-05-23 | **Memo Engine** added: `upload-voice-sample`, `voice-samples`, `voice-samples/:id`, `generate-all-memos`, `list-memos`, `memos/:id`. **Breaking change to `generate-memo`:** body is now `{ client_id, month }` (previously `{ client_id, market_context }`); response is a saved memo row (previously a four-section JSON object); calls now persist into `client_memos`. The old shape was never used in production. |
| v1.3.0 | 2026-05-24 | **Portfolio analytics — Batch 1** added: `performance`, `income`, `rmd-status`, `ips-status`. All four are read-only, client-scoped, 100 req/hour. |
| v1.3.1 | 2026-05-24 | **Portfolio analytics — Batch 2** added: `recent-activity`, `alts-ingest-status`, `drift-events`, `overdue-reviews`. First two are client-scoped; `drift-events` and `overdue-reviews` are firm-scoped. |
| v1.4.0 | 2026-05-24 | **Tax + firm analytics** added: `tax-position`, `tax-calendar`, `tax-opportunities`, `rmd-dashboard`, `firm-tax-calendar`, `firm-alts-ingest-summary`. Heavy compute endpoints (tax-position, tax-opportunities, rmd-dashboard, firm-tax-calendar) are cached with a **1-hour TTL** in `portfolio_compute_cache`; cache is invalidated on CSV bulk-import, Altruist sync, and the daily Altruist snapshot. Cached responses return identical bodies plus `X-Cache: HIT/MISS` and `X-Cache-Computed-At: <iso8601>` response headers. |
| v1.5.0 | 2026-05-24 | **Meeting Prep** added: `meeting-prep`. Composes 7 parallel data slices + a 5-section AI narrative in a single call. Hard rate-limit 30 req/hour (Claude calls). Bundle cached 1h, narrative regenerates fresh per call. Partial-bundle pattern — any slice that fails or times out (5s per query) surfaces as `data.errors[]`; the call never fails wholesale. Persists to `meeting_game_plans` with `bundle.source='api'` marker so audit can distinguish API-driven from in-app generations. `X-Bundle-Cache: HIT/MISS` response header. |
| v1.6.0 | 2026-05-24 | **MCP server** added at `/api/mcp`. All 25 v1 endpoints exposed as MCP tools (22 read + 3 write) for use from Claude Desktop and any MCP-compatible client. Reuses existing v1 API keys as Bearer tokens — zero new auth. Streamable HTTP transport, JSON-RPC 2.0 over POST. See the "MCP server" section above for setup. |

---

### GET /api/v1/recent-activity

Returns the most recent `activity_logs` entries linked to a single client.

**When to use it.** When you need an audit trail of advisor actions on a client — portfolio uploads, IPS changes, commentary generation, drift resolutions, RMD recordings, alts imports. Covers all three ways Axfolio links an activity to a client.

**Authentication.** Bearer API key.

**Query parameters:**

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |
| `limit` | no | integer (1–50) | Number of records to return. Default: `20`. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/recent-activity?client_id=3c4d5e6f-7890-abcd-ef01-234567890abc&limit=10" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const params = new URLSearchParams({ client_id: '3c4d5e6f-...', limit: '10' });
const res = await fetch(`https://axfolio.io/api/v1/recent-activity?${params}`, {
  headers: { Authorization: 'Bearer ak_live_...' },
});
const { data } = await res.json();
data.activities.forEach(a => console.log(a.at, a.action));
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/recent-activity',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '3c4d5e6f-...', 'limit': 10},
)
for a in r.json()['data']['activities']:
    print(a['at'], a['action'])
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
    "client_name": "Sarah Chen",
    "activities": [
      {
        "id": "...",
        "at": "2026-05-20T09:00:00Z",
        "action": "ips.drift_detected",
        "entity_type": "ips_drift_events",
        "entity_id": "...",
        "metadata": { "client_id": "...", "asset_class": "Equity", "severity": "high" }
      }
    ],
    "count": 1,
    "limit": 10
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `activities[].action` | string | Dot-namespaced action string, e.g. `ips.drift_detected`, `portfolio.saved`, `rmd_execution.recorded`. |
| `activities[].metadata` | object | Trimmed subset of the raw metadata — only fields relevant to the action are included. |
| `count` | integer | Number of records returned. May be less than `limit` if fewer records exist. |

#### Error responses

| Status | `error` message | What to do |
|--------|-----------------|------------|
| 400 | `Missing required query param: client_id` | Add the parameter. |
| 404 | `Client not found or does not belong to this firm.` | Re-check the `/clients` list. |

---

### GET /api/v1/alts-ingest-status

Returns the alternatives AI document ingestion queue status and cash-flow verification counts for a single client.

**When to use it.** When you need to know whether a client has unverified imported cash flows, documents awaiting advisor review, or failed extraction attempts that need attention.

**Authentication.** Bearer API key.

**Query parameters:**

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/alts-ingest-status?client_id=3c4d5e6f-7890-abcd-ef01-234567890abc" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/alts-ingest-status?client_id=3c4d5e6f-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
if (data.has_action_required) console.log('Pending verifications:', data.pending_verification_count);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/alts-ingest-status',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '3c4d5e6f-...'},
)
data = r.json()['data']
if data['has_action_required']:
    print('Pending verifications:', data['pending_verification_count'])
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "3c4d5e6f-7890-abcd-ef01-234567890abc",
    "client_name": "Sarah Chen",
    "pending_verification_count": 3,
    "needs_review_count": 1,
    "failed_count": 0,
    "has_action_required": true,
    "recent_imports": [
      { "id": "...", "original_filename": "Q1-capital-call.pdf", "status": "confirmed", "confirmed_at": "2026-05-18T...", "created_at": "2026-05-17T..." }
    ],
    "needs_review_items": [
      { "id": "...", "original_filename": "Q2-distribution.xlsx", "created_at": "2026-05-20T..." }
    ],
    "failed_items": []
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `pending_verification_count` | integer | AI-extracted cash flows imported but not yet verified by an advisor. |
| `needs_review_count` | integer | Documents whose AI extraction needs advisor review before importing. |
| `failed_count` | integer | Extraction attempts that failed (last 3 shown in `failed_items`). |
| `has_action_required` | boolean | `true` if any of the three counts above is non-zero. |

#### Error responses

| Status | `error` message | What to do |
|--------|-----------------|------------|
| 400 | `Missing required query param: client_id` | Add the parameter. |
| 404 | `Client not found or does not belong to this firm.` | Re-check the `/clients` list. |

---

### GET /api/v1/drift-events

Returns all open (unresolved) IPS drift events across the entire firm, plus a per-client summary.

**When to use it.** When you want a firm-wide view of IPS compliance alerts — useful for a compliance dashboard or a daily digest of outstanding items. No `client_id` parameter — results are always scoped to the authenticated API key's firm.

**Authentication.** Bearer API key.

**Query parameters.** None.

#### Example request

```bash
curl "https://axfolio.io/api/v1/drift-events" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const res = await fetch('https://axfolio.io/api/v1/drift-events', {
  headers: { Authorization: 'Bearer ak_live_...' },
});
const { data } = await res.json();
console.log(`${data.total_open_events} open across ${data.clients_with_open_drift} clients (${data.high_severity_count} high severity)`);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/drift-events',
    headers={'Authorization': 'Bearer ak_live_...'},
)
data = r.json()['data']
print(f"{data['total_open_events']} open across {data['clients_with_open_drift']} clients ({data['high_severity_count']} high)")
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "total_open_events": 3,
    "clients_with_open_drift": 2,
    "high_severity_count": 1,
    "events": [
      {
        "id": "...",
        "client_id": "...",
        "client_name": "Sarah Chen",
        "detected_at": "2026-05-20T09:00:00Z",
        "asset_class": "Equity",
        "current_pct": 67.2,
        "target_pct": 60,
        "band_lower": 55,
        "band_upper": 65,
        "drift_pp": 7.2,
        "severity": "high"
      }
    ],
    "by_client": [
      {
        "client_id": "...",
        "client_name": "Sarah Chen",
        "open_event_count": 2,
        "high_severity_count": 1,
        "medium_severity_count": 1,
        "oldest_event_date": "2026-05-10"
      }
    ]
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `events` | array | Flat list of all open events, sorted by severity (high → low) then by age. |
| `events[].drift_pp` | decimal | Percentage-point deviation from the IPS target. |
| `events[].severity` | string | `high` / `medium` / `low`. |
| `by_client` | array | Per-client summary. Sorted by `high_severity_count` descending. |

---

### GET /api/v1/overdue-reviews

Returns all clients in the firm whose last review was more than 365 days ago, or who have never been reviewed.

**When to use it.** When you need to drive a review workflow — surfacing clients whose annual review cycle is overdue. No `client_id` parameter.

**Authentication.** Bearer API key.

**Query parameters.** None.

#### Example request

```bash
curl "https://axfolio.io/api/v1/overdue-reviews" \
  -H "Authorization: Bearer ak_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
```

```javascript
const res = await fetch('https://axfolio.io/api/v1/overdue-reviews', {
  headers: { Authorization: 'Bearer ak_live_...' },
});
const { data } = await res.json();
console.log(`${data.overdue_count} of ${data.total_clients} clients overdue`);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/overdue-reviews',
    headers={'Authorization': 'Bearer ak_live_...'},
)
data = r.json()['data']
print(f"{data['overdue_count']} of {data['total_clients']} overdue")
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "overdue_count": 4,
    "total_clients": 28,
    "threshold_days": 365,
    "clients": [
      { "id": "...", "name": "Robert Kim", "status": "never_reviewed", "last_reviewed_at": null, "days_since_review": null },
      { "id": "...", "name": "Sarah Chen", "status": "overdue", "last_reviewed_at": "2025-04-01T...", "days_since_review": 418 }
    ]
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `threshold_days` | integer | Review cycle length in days. Always `365`. |
| `clients[].status` | string | `never_reviewed` or `overdue`. |
| `clients[].days_since_review` | integer \| null | Calendar days since the last review. `null` for `never_reviewed` clients. |

The list is sorted: `never_reviewed` clients first, then `overdue` sorted by `days_since_review` descending (longest overdue at the top).

---

### GET /api/v1/tax-position

Current-year tax position for one client. Returns unrealized gain/loss by holding, total harvestable losses, year-to-date realized activity (sales + realized G/L when the custodian feed carries cost basis), declared capital-gains distribution exposure, and cash-on-hand vs an estimated tax liability.

**Source data.** Altruist positions when the client is connected; otherwise the most recent CSV portfolio. Realized activity requires Altruist transactions — CSV-only clients get `realized.available: false`.

**Caching.** 1-hour TTL keyed by `(firm_id, client_id, tax_year)`. Invalidated on CSV bulk-import, Altruist sync, and the daily Altruist snapshot. Response headers: `X-Cache: HIT|MISS` and `X-Cache-Computed-At`.

#### Query parameters

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |
| `tax_year` | no | integer | Tax year (4 digits). Defaults to the current calendar year. Must be 2000 ≤ year ≤ current year + 1. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/tax-position?client_id=8b1c2d3e-..." \
  -H "Authorization: Bearer ak_live_..."
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/tax-position?client_id=8b1c2d3e-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
console.log('Harvestable loss:', data.harvestable.totalHarvestableLoss);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/tax-position',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '8b1c2d3e-...', 'tax_year': 2026},
)
data = r.json()['data']
print('Harvestable loss:', data['harvestable']['totalHarvestableLoss'])
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "8b1c2d3e-...",
    "client_name": "Sarah Chen",
    "source_type": "altruist",
    "available": true,
    "asOfDate": "2026-05-24",
    "year": 2026,
    "hasPositions": true,
    "unrealized": { "totalMarketValue": 1450000, "totalUnrealizedGL": 87500, "withBasisCount": 18, "holdings": [...] },
    "harvestable": { "totalHarvestableLoss": -12400, "lossPositionCount": 3, "lossHoldings": [...] },
    "realized": { "available": true, "gainLossAvailable": true, "year": 2026, "saleCount": 4, "totalProceeds": 81200, "totalRealizedGL": 3100, "shortTermGL": -800, "longTermGL": 3900 },
    "capGainsDist": { "distributions": [...], "count": 1, "totalEstimated": 2150 },
    "cashForTax": { "cashValue": 22000, "liabilityAvailable": true, "estimatedLiability": 800, "sufficient": true, "shortfall": 0 },
    "taxRateAssumptions": { "shortTerm": 0.35, "longTerm": 0.15, "blended": 0.20 }
  }
}
```

---

### GET /api/v1/tax-calendar

Upcoming tax events for one client over the next 90 days. Seven event types:

| Type | When it appears |
|------|-----------------|
| `rmd_deadline` | Client is subject to an RMD this year and the deadline is within 90 days (or already past). |
| `estimated_tax` | The next federal quarterly estimated-tax payment date falls within 90 days. |
| `tlh_window` | Client holds positions sitting at an unrealized loss and December 31 is within 90 days. |
| `roth_window` | December 31 is within 90 days (Roth conversion year-end deadline). |
| `k1_receipt` | Client has alt holdings AND today falls inside the March 1 – September 30 K-1 receipt window. |
| `cap_gains_dist` | Held fund has a declared capital-gains distribution with an ex-date within 90 days. |
| `wash_sale_caution` | A TLH plan was generated for this client within the last 30 days; surfaces the wash-sale window expiry. |

Each event includes `daysUntil` (signed), `urgency` (`info` / `attention` / `warning` / `urgent` / `critical`), `actionRequired`, and `estimatedDollarImpact` when knowable.

**Caching.** Skipped — events are pure date math + light reads.

#### Query parameters

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/tax-calendar?client_id=8b1c2d3e-..." \
  -H "Authorization: Bearer ak_live_..."
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/tax-calendar?client_id=8b1c2d3e-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
data.events.forEach(e => console.log(e.daysUntil, e.urgency, e.title));
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/tax-calendar',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '8b1c2d3e-...'},
)
for e in r.json()['data']['events']:
    print(e['daysUntil'], e['urgency'], e['title'])
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "8b1c2d3e-...",
    "client_name": "Sarah Chen",
    "as_of_date": "2026-05-24",
    "horizon_days": 90,
    "event_count": 3,
    "events": [
      { "type": "estimated_tax", "title": "Estimated Tax Payment Due", "date": "2026-06-15", "daysUntil": 22, "urgency": "attention", "actionRequired": true, "estimatedDollarImpact": null },
      { "type": "k1_receipt", "title": "K-1 Expected — Blackstone Real Estate IX", "date": "2026-09-30", "daysUntil": 129, "urgency": "info", "actionRequired": false, "estimatedDollarImpact": null },
      { "type": "rmd_deadline", "title": "RMD Deadline", "date": "2026-12-31", "daysUntil": 221, "urgency": "info", "actionRequired": true, "estimatedDollarImpact": 24600 }
    ]
  }
}
```

---

### GET /api/v1/tax-opportunities

Ranked actionable tax opportunities for one client. Four types, sorted by `estimatedImpact` descending across all four:

| Type | What it means |
|------|---------------|
| `tlh` | Tax-loss harvesting — one or more tax lots at a loss past the harvest threshold (-$1,000). |
| `ltg_realization` | Long-term lots at a gain — eligible to realize at LTCG rates rather than short-term. |
| `charitable_candidate` | Holdings appreciated ≥ 25% — top candidates for a donor-advised-fund donation. |
| `mf_distribution_avoidance` | Funds with an upcoming declared capital-gains distribution. |

**TLH source data.** Altruist tax lots only — CSV clients fall back to a degraded mode that only surfaces `mf_distribution_avoidance` opportunities (no per-lot data).

**Caching.** 1-hour TTL keyed by `(firm_id, client_id, today)`. Invalidated on the same write paths as `/v1/tax-position`. Response headers include `X-Cache`.

#### Query parameters

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/tax-opportunities?client_id=8b1c2d3e-..." \
  -H "Authorization: Bearer ak_live_..."
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/tax-opportunities?client_id=8b1c2d3e-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
console.log('Total tax-dollar impact:', data.totalEstimatedImpact);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/tax-opportunities',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '8b1c2d3e-...'},
)
data = r.json()['data']
print('Total tax-dollar impact:', data['totalEstimatedImpact'])
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "client_id": "8b1c2d3e-...",
    "client_name": "Sarah Chen",
    "source_type": "altruist",
    "available": true,
    "asOfDate": "2026-05-24",
    "hasTaxLots": true,
    "opportunities": [
      { "id": "tlh:META", "type": "tlh", "ticker": "META", "estimatedImpact": 1820, "priority": "medium", "actionable": true, "summary": "Harvest 2 lot(s) of META at a $5,200 loss" },
      { "id": "charitable:NVDA", "type": "charitable_candidate", "ticker": "NVDA", "estimatedImpact": 1430, "priority": "medium", "summary": "NVDA is up 312% — donating appreciated shares avoids ~$1,430 in capital-gains tax" }
    ],
    "totalEstimatedImpact": 3250,
    "counts": { "tlh": 1, "ltg_realization": 0, "charitable_candidate": 1, "mf_distribution_avoidance": 0, "total": 2 }
  }
}
```

#### Notes

- `actionable` is `true` when the underlying compute confirms the opportunity has no blocking conflict (TLH: wash-sale window is clear; others: always true).
- `priority` thresholds: `high` ≥ $2,000 impact, `medium` ≥ $500, `low` otherwise.
- Replacement-security candidates (for TLH) are intentionally broad — the advisor must confirm they're not "substantially identical" before trading.

---

### GET /api/v1/rmd-dashboard

Firm-wide Required Minimum Distribution dashboard. Returns every RMD-eligible client (client age ≥ SECURE Act 2.0 start age based on date of birth) sorted by urgency: overdue → critical → warning → attention → info → complete.

Summary counts include `no_dob` (clients without a date of birth on file — uncomputable) and `pre_rmd_age` (clients with a DOB but not yet at the threshold age) for completeness.

**Caching.** 1-hour TTL keyed by `(firm_id, today)`. Invalidated on bulk-import + Altruist sync + daily snapshot.

#### Example request

```bash
curl "https://axfolio.io/api/v1/rmd-dashboard" \
  -H "Authorization: Bearer ak_live_..."
```

```javascript
const res = await fetch('https://axfolio.io/api/v1/rmd-dashboard', {
  headers: { Authorization: 'Bearer ak_live_...' },
});
const { data } = await res.json();
console.log(`${data.summary.overdue} overdue, ${data.summary.critical} critical`);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/rmd-dashboard',
    headers={'Authorization': 'Bearer ak_live_...'},
)
s = r.json()['data']['summary']
print(f"{s['overdue']} overdue, {s['critical']} critical")
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "tax_year": 2026,
    "clients": [
      { "client_id": "...", "client_name": "Robert Kim", "status": "overdue", "urgency": "overdue", "required_amount_usd": 18200, "distributed_ytd_usd": 0, "remaining_usd": 18200, "deadline": "2026-04-01", "days_until_deadline": -53, "is_first_rmd_year": true },
      { "client_id": "...", "client_name": "Sarah Chen", "status": "in_progress", "urgency": "warning", "required_amount_usd": 24600, "distributed_ytd_usd": 10000, "remaining_usd": 14600, "deadline": "2026-12-31", "days_until_deadline": 25 }
    ],
    "summary": { "total_eligible": 12, "overdue": 1, "critical": 0, "warning": 1, "attention": 0, "info": 8, "complete": 2, "no_dob": 8, "pre_rmd_age": 27 }
  }
}
```

---

### GET /api/v1/firm-tax-calendar

Firm-wide tax-calendar roll-up. Returns the same shape as the firm Tax Intelligence panel on the Atlas admin view:

- `summary` — `getFirmTaxCalendarSummary` output: total event count, urgent/critical counts, `byType` breakdown, single most-urgent `nextDeadline`.
- `total_harvestable_loss` — sum of estimated dollar impacts across all `tlh_window` events.
- `per_client` — per-client event lists, each with `event_count` and the full events array.

**Position data.** Firm-wide roll-up uses CSV portfolio positions only — Altruist-custodied positions are intentionally excluded from this aggregate because firm-wide Altruist queries are too heavy. The per-client `/v1/tax-calendar` endpoint covers Altruist positions for individual clients.

**Caching.** 1-hour TTL keyed by `(firm_id, today)`. Invalidated on bulk-import + Altruist sync + daily snapshot.

#### Example request

```bash
curl "https://axfolio.io/api/v1/firm-tax-calendar" \
  -H "Authorization: Bearer ak_live_..."
```

```javascript
const res = await fetch('https://axfolio.io/api/v1/firm-tax-calendar', {
  headers: { Authorization: 'Bearer ak_live_...' },
});
const { data } = await res.json();
console.log(`${data.summary.totalEvents} events, harvestable: ${data.total_harvestable_loss}`);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/firm-tax-calendar',
    headers={'Authorization': 'Bearer ak_live_...'},
)
data = r.json()['data']
print(f"{data['summary']['totalEvents']} events, harvestable: {data['total_harvestable_loss']}")
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "firm_id": "...",
    "tax_year": 2026,
    "as_of_date": "2026-05-24",
    "horizon_days": 90,
    "summary": { "totalEvents": 47, "urgentCount": 2, "criticalCount": 1, "byType": { "estimated_tax": 1, "rmd_deadline": 12, "tlh_window": 8, "roth_window": 12, "k1_receipt": 14 }, "nextDeadline": { "clientName": "Robert Kim", "event": { "type": "rmd_deadline", "urgency": "overdue", ... } } },
    "total_harvestable_loss": -184200,
    "per_client": [
      { "client_id": "...", "client_name": "Robert Kim", "event_count": 3, "events": [...] }
    ]
  }
}
```

---

### GET /api/v1/firm-alts-ingest-summary

Firm-wide alternatives ingestion status. Mirrors the shape returned by the Atlas `getFirmAltsIngestSummary` tool. Counts use PostgREST's exact-count headers — no large data scans.

**Caching.** Skipped — counts are cheap.

#### Example request

```bash
curl "https://axfolio.io/api/v1/firm-alts-ingest-summary" \
  -H "Authorization: Bearer ak_live_..."
```

```javascript
const res = await fetch('https://axfolio.io/api/v1/firm-alts-ingest-summary', {
  headers: { Authorization: 'Bearer ak_live_...' },
});
const { data } = await res.json();
if (data.has_action_required) console.log(`Pending: ${data.firm_pending_verification_count}, Needs review: ${data.firm_needs_review_count}`);
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/firm-alts-ingest-summary',
    headers={'Authorization': 'Bearer ak_live_...'},
)
data = r.json()['data']
if data['has_action_required']:
    print(f"Pending: {data['firm_pending_verification_count']}, Needs review: {data['firm_needs_review_count']}")
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "firm_pending_verification_count": 12,
    "firm_needs_review_count": 3,
    "firm_failed_count": 1,
    "oldest_unverified_date": "2026-05-12T14:22:00.000Z",
    "oldest_unverified_client_id": "...",
    "has_action_required": true
  }
}
```

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `firm_pending_verification_count` | integer | `alt_cash_flows` rows firm-wide with `verification_status='pending'`. |
| `firm_needs_review_count` | integer | `alt_ingest_queue` rows firm-wide with `status='needs_review'`. |
| `firm_failed_count` | integer | `alt_ingest_queue` rows firm-wide with `status='failed'`. |
| `oldest_unverified_date` | string \| null | `recorded_at` of the oldest pending cash-flow verification (ISO 8601). `null` when none. |
| `has_action_required` | boolean | `true` when there is at least one pending verification OR needs-review item. |

---

### GET /api/v1/meeting-prep

The composed orchestrator. Returns everything an advisor needs before a meeting with one client: a 7-slice deterministic bundle and a 5-section AI narrative ready to drop into meeting notes.

This is the most expensive endpoint in v1 — each call fans out to seven parallel queries and one Claude completion. Rate-limited to **30 requests/hour** per API key. Vercel `maxDuration: 60` (a complex client can take 20–40s).

**Caching.** The assembled bundle is cached for 1 hour (keyed by firm + client + day + document selection). The AI narrative regenerates fresh on every call so the language stays current. `X-Bundle-Cache: HIT|MISS` header tells you which path you hit.

**Persistence.** Every successful call writes a row to `meeting_game_plans`. The bundle marks itself with `source: 'api'` so audit can distinguish API-driven generations from in-app ones. The persisted `id` is returned as `data.plan_id`.

**Partial-bundle pattern.** Each of the six slice fetchers (book review, IPS, upcoming events, income, last review, planning documents) has a 5-second per-query timeout. If a slice fails or times out, the bundle still returns — that slice surfaces as `{ error: '...' }` and the failure is recorded in the top-level `data.errors[]` array. The call never fails wholesale.

**Narrative failure.** If the Claude call itself fails (model unavailable, timeout, etc.), the bundle is still returned with `data.narrative: null` and `data.narrative_error: "<message>"`. The deterministic bundle alone is still useful.

#### Query parameters

| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `client_id` | yes | UUID string | The client's `id` from `GET /api/v1/clients`. |
| `document_ids` | no | comma-separated UUIDs | Specific `client_documents.id` values to summarize in the planning-documents slice. If omitted, the three most recent already-summarized documents are picked automatically. |

#### Example request

```bash
curl "https://axfolio.io/api/v1/meeting-prep?client_id=8b1c2d3e-..." \
  -H "Authorization: Bearer ak_live_..."
```

```javascript
const res = await fetch(
  'https://axfolio.io/api/v1/meeting-prep?client_id=8b1c2d3e-...',
  { headers: { Authorization: 'Bearer ak_live_...' } },
);
const { data } = await res.json();
console.log(data.narrative);  // markdown 5-section game plan
// or: data.bundle.snapshot, data.bundle.book_review, etc. for raw data
```

```python
import requests

r = requests.get(
    'https://axfolio.io/api/v1/meeting-prep',
    headers={'Authorization': 'Bearer ak_live_...'},
    params={'client_id': '8b1c2d3e-...'},
    timeout=60,  # Claude completion can take 20-40s
)
data = r.json()['data']
print(data['narrative'])  # the markdown game plan
```

#### Example success response (200)

```json
{
  "success": true,
  "data": {
    "bundle": {
      "client": "Sarah Chen",
      "client_id": "8b1c2d3e-...",
      "generated_at": "2026-05-24T18:42:11.000Z",
      "source": "api",
      "snapshot": { "total_market_value": 1450000, "position_count": 24, "allocation_pct": { "equity": 62.4, "fixed_income": 28.1, "alternatives": 7.0, "cash": 2.5 }, "top_holdings": [...], "portfolio_score": { "overall": 78, "label": "Solid", "drivers": [...] } },
      "book_review": { "available": true, "run_date": "2026-05-22", "flagged": true, "open_signals": ["TLH opportunity — META at $5.2K unrealized loss"], "active_signal_keys": ["tlhOpportunity"] },
      "ips": { "ips": {...}, "open_drift_events_count": 1, "pending_signoffs_count": 0, "open_drift_events": [...], "pending_signoffs": [], "recent_signoffs": [...] },
      "upcoming_events": { "as_of": "2026-05-24", "lookforward_days": 90, "counts": { "ex_dividend": 8, "split": 0, "total": 8 }, "events": [...] },
      "expected_income": { "expected_annual_income": 18420, "yield_pct": 1.27, "top_contributors": [...] },
      "last_review": { "last_reviewed_at": "2026-04-08T...", "last_reviewed_label": "Reviewed 1 month(s) ago", "is_stale": false },
      "planning_documents": { "count": 1, "documents": [{ "document_id": "...", "file_name": "Q1-2026 review notes.pdf", "summary": "..." }] }
    },
    "narrative": "## Client Snapshot\nSarah Chen holds $1.45M across 24 positions...\n\n## Open Signals\n- ...\n\n## Document Insights\n...\n\n## Recommended Agenda\n1. ...\n\n## Action Items\n- ...",
    "plan_id": "abc12345-..."
  }
}
```

#### Example degraded response (200 — partial bundle)

```json
{
  "success": true,
  "data": {
    "bundle": { "client": "Sarah Chen", "snapshot": {...}, "book_review": { "error": "Timeout after 5000ms" }, "ips": {...}, "upcoming_events": {...}, "expected_income": {...}, "last_review": {...}, "planning_documents": {...} },
    "narrative": "## Client Snapshot ...",
    "errors": [{ "slice": "book_review", "error": "Timeout after 5000ms" }],
    "plan_id": "..."
  }
}
```

#### Example narrative-failure response (200)

```json
{
  "success": true,
  "data": {
    "bundle": {...},
    "narrative": null,
    "narrative_error": "Claude API 529: overloaded"
  }
}
```

`plan_id` is omitted from this response — the row is only persisted when the narrative succeeds (the column is `NOT NULL`).

#### Response field reference

| Field | Type | Description |
|-------|------|-------------|
| `data.bundle.source` | string | Always `"api"` for v1 calls. The in-app feature writes rows with no such marker. |
| `data.bundle.snapshot` | object | Total AUM, position count, allocation across asset classes, top 5 holdings by weight, portfolio score + drivers. |
| `data.bundle.book_review` | object | Most recent persisted Book Review signals for this client. `available: false` when no run covers this client. |
| `data.bundle.ips` | object | Active IPS + open drift events + supervisor sign-off state. Same shape returned by `/v1/ips-status`. |
| `data.bundle.upcoming_events` | object | Ex-dividend dates + stock splits over the next 90 days. |
| `data.bundle.expected_income` | object | Next-12-month dividend income from the dividend cache. Same shape returned by `/v1/income`. |
| `data.bundle.last_review` | object | Last `client_reviews` row + a human label + `is_stale` flag (true when ≥365 days). |
| `data.bundle.planning_documents` | object | AI summaries of selected (or auto-picked) `client_documents`. Documents without a summary on file are reported in `unreadable_files[]`. |
| `data.narrative` | string \| null | 5-section markdown game plan. `null` when narrative generation failed (see `narrative_error`). |
| `data.narrative_error` | string | Present only when narrative generation failed; the bundle alone is still useful. |
| `data.errors` | array | Present only when one or more slices failed. Each entry: `{ slice, error }`. |
| `data.plan_id` | UUID | The persisted `meeting_game_plans.id`. Omitted if persistence was skipped (narrative failure) or failed. |
