# GPAgent API v1

Programmatic access to your fund data. Every endpoint returns JSON and is authenticated with a bearer API key. GPAgent is the source of truth — any client (including [OpenClaw](https://github.com/anthropics/claude-code)) should read and write through this API rather than keep a local mirror.

- **Base URL** (production): `https://www.gpagent.ai/api/v1`
- **Base URL** (local dev): `http://localhost:3000/api/v1`
- **Auth header**: `Authorization: Bearer gpa_...`
- **Content type**: `application/json`

Create and manage keys at `/app/settings/api` in the web UI. Keys are per-user and can be revoked at any time.

> **Use the `www.` host.** The apex `gpagent.ai` issues a `307` redirect to `www.gpagent.ai`, and most HTTP clients (curl, fetch, reqwest) drop the `Authorization` header on cross-host redirects as a security measure. Pointing at `www.gpagent.ai` directly avoids an unauthenticated retry and the resulting `401`.

---

## Conventions

### Identifiers

Portfolio companies are addressed by a URL-safe `slug` (e.g. `phases`, `anthum-ai`). If you don't supply one on create, the API derives it from `name`. Slugs are unique per vehicle and stable across renames.

### Vehicle scoping

Every endpoint operates on a single vehicle (fund or SPV). If your API key has access to exactly one vehicle, it's selected automatically. If you have multiple, pass `?vehicleId=...` on every request — the API returns `400` with the list of accessible vehicle IDs otherwise.

### Soft delete

`DELETE` on most resources is a soft delete — the row is marked with `deletedAt` and excluded from list and detail reads by default. Pass `?includeDeleted=true` on a list/detail GET to bypass the filter (useful for admin/debug views).

For the top-level `/portfolio/{slug}` and `/deals/{slug}` resources, re-creating via `PUT` with the same slug clears `deletedAt` and restores the record.

Portfolio intelligence resources (investor updates, customers, news items) expose explicit `POST /.../restore` endpoints for soft-deleted rows. Operational/derived resources (metric snapshots, data gaps, fund stats snapshots) are HARD-deleted instead — they are recomputable from upstream sources, so we don't keep tombstones. Hard deletes return `204`.

### Idempotency

| Verb                       | Behavior                                                      |
| -------------------------- | ------------------------------------------------------------- |
| `POST /portfolio`          | Strict create. Returns `409` if the slug already exists.      |
| `PUT /portfolio/{slug}`    | Upsert. Safe to retry. Replaces the full record at that slug. |
| `PATCH /portfolio/{slug}`  | Partial update. `404` if the slug does not exist.             |
| `DELETE /portfolio/{slug}` | Soft delete. Safe to retry (idempotent).                      |

For sync use-cases, prefer `PUT` — it's the only write verb safe to retry after a network failure without worrying about duplicate state.

### Errors

All errors return JSON with at least an `error` field:

```json
{ "error": "Invalid or revoked API key" }
```

Validation errors include a zod `issues` array so clients can point at the offending field.

| Status | Meaning                                                                                        |
| ------ | ---------------------------------------------------------------------------------------------- |
| `400`  | Invalid JSON, failed validation, or ambiguous vehicle scope                                    |
| `401`  | Missing, malformed, or revoked API key                                                         |
| `404`  | Resource not found, soft-deleted, OR exists in another organization (see existence-mask below) |
| `409`  | Slug conflict on `POST /portfolio`                                                             |

### Authentication & Authorization

API keys are scoped to one or more organizations (`Membership` rows on the underlying user). Every portfolio-intel endpoint enforces this scope.

**Existence-mask convention (since Phase 4.6, 2026-05-08):** when a request targets a resource that exists but belongs to an organization the API key isn't a member of, the response is **`404 Not Found`** with the same JSON body as a genuine "resource doesn't exist" response. The status code, the `error.code` (`NOT_FOUND`), and the `error.message` are identical between those two cases.

The reason: a 403/404 split is a side-channel. An attacker holding a valid `gpa_*` key could iterate company slugs, vehicle ids, news-item ids, etc., and learn which exist by observing `403` (exists, wrong org) vs `404` (doesn't exist). Returning `404` everywhere closes that channel.

Forensic distinction is preserved server-side via `console.log` audit lines (look for `[audit] cross-org`); only the HTTP response is masked.

### Breaking changes

- **2026-05-09 (Phase 4.8a):** `GET /portfolio` and `GET/PUT/PATCH/DELETE /portfolio/{slug}` now honor a `?vehicleSlug=<slug>` query param. Without it, multi-vehicle GPs were silently served their first vehicle (the same class of mistake as the Phase 4.7 POST default). Missing/empty values fall back to the historical default for backwards-compat. Bad slugs return `404` (existence-mask).
- **2026-05-08 (Phase 4.7):** `POST /portfolio` now requires an explicit `vehicleSlug` in the request body. Previously the endpoint used the caller's first vehicle as a silent default, which on 2026-05-08 caused six personal pre-fund investments to be created under the wrong vehicle's id. Required fields enforce a decision; defaults invite drift. Discover valid `vehicleSlug` values via the new [`GET /vehicles`](#get-vehicles) endpoint. Requests missing `vehicleSlug` now return `400`; requests with a slug that doesn't exist (or belongs to another organization) return `404` (existence-mask).
- **2026-05-08 (Phase 4.6):** endpoints that previously returned `403 FORBIDDEN` for cross-org access now return `404 NOT_FOUND` instead. Affects every `/api/v1/portfolio/*` endpoint that takes a slug, vehicle id, or resource id (companies, investor updates, metric snapshots, customers, data gaps, news items, stats snapshots). Update any client that branched on the `403` status — it can't happen any more for cross-org access. `403` is no longer emitted by the portfolio-intel surface.

---

## Endpoints

### `GET /portfolio`

List a fund vehicle, its fund model, and all (non-soft-deleted) portfolio companies.

Accepts `?vehicleSlug=<slug>` (Phase 4.8a, 2026-05-09). When omitted, the endpoint falls back to the caller's default vehicle for backwards-compat. When provided, the slug is resolved against the caller's organizations using the same existence-mask convention as `POST /portfolio` — a slug that doesn't exist (or belongs to another organization) returns `404`.

```bash
# Default vehicle
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio

# Explicit vehicle (multi-vehicle GPs should always pass this)
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/portfolio?vehicleSlug=pre-fund-i"
```

The per-slug routes (`GET/PUT/PATCH/DELETE /portfolio/{slug}`) accept the same `?vehicleSlug=` query param for the same reason.

**Response `200`**

```json
{
  "vehicle": {
    "id": "cmx...",
    "name": "Element 14 Capital Fund I",
    "type": "FUND",
    "slug": "fund-i"
  },
  "fund": {
    "targetFundSize": 1050000,
    "targetCompanyCount": 15,
    "maxConcentrationPct": 25,
    "totalDeployed": 275000,
    "deploymentPct": 26.2,
    "avgCheckSize": 27500,
    "avgValuation": 26100000,
    "companyCount": 10
  },
  "companies": [
    {
      "id": "cmx...",
      "slug": "phases",
      "name": "Phases",
      "website": "https://phases.so",
      "sector": "Developer Tools",
      "stage": "pre-seed",
      "checkSize": 25000,
      "postMoneyValuation": 20000000,
      "investmentDate": "2025-11-26T00:00:00.000Z",
      "instrumentType": "SAFE",
      "status": "ACTIVE",
      "notes": "YC S25. AI clinical trial recruitment.",
      "logoUrl": null,
      "createdAt": "2025-11-26T19:00:00.000Z",
      "updatedAt": "2026-03-12T10:00:00.000Z"
    }
  ],
  "sectors": [{ "sector": "AI/ML", "count": 3, "deployed": 85000 }]
}
```

---

### `POST /portfolio`

Create a new portfolio company in the explicitly-selected vehicle. Strict create — if a company with the same slug already exists, returns `409`. Use `PUT /portfolio/{slug}` instead for safe-retry upserts.

> **Hard requirement (Phase 4.7, 2026-05-08):** `vehicleSlug` is required. The endpoint no longer falls back to a default vehicle. This prevents accidental vehicle co-mingling — e.g. a personal pre-fund investment landing under the LP-backed Fund I vehicle. Discover valid slugs via [`GET /vehicles`](#get-vehicles).

**Request body** (JSON)

| Field                | Type              | Required | Notes                                                                     |
| -------------------- | ----------------- | -------- | ------------------------------------------------------------------------- |
| `name`               | string            | yes      | Display name                                                              |
| `vehicleSlug`        | string            | **yes**  | Slug of the vehicle this investment belongs to. List via `GET /vehicles`. |
| `slug`               | string            | no       | Lowercase alphanumeric + hyphens. Derived from `name` if omitted.         |
| `website`            | string (URL)      | no       |                                                                           |
| `sector`             | string            | no       |                                                                           |
| `stage`              | string            | no       | e.g. `pre-seed`, `seed`, `series-a`                                       |
| `checkSize`          | number            | yes      | USD, non-negative                                                         |
| `postMoneyValuation` | number            | no       | USD, non-negative                                                         |
| `investmentDate`     | string (ISO 8601) | yes      |                                                                           |
| `instrumentType`     | enum              | yes      | `SAFE` \| `EQUITY` \| `CONVERTIBLE`                                       |
| `status`             | enum              | no       | `ACTIVE` (default) \| `MARKED_UP` \| `WRITTEN_DOWN` \| `EXITED`           |
| `notes`              | string            | no       |                                                                           |
| `logoUrl`            | string (URL)      | no       |                                                                           |

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Phases",
    "vehicleSlug": "fund-i",
    "website": "https://phases.so",
    "sector": "Developer Tools",
    "stage": "pre-seed",
    "checkSize": 25000,
    "postMoneyValuation": 20000000,
    "investmentDate": "2025-11-26",
    "instrumentType": "SAFE",
    "notes": "YC S25."
  }' \
  https://www.gpagent.ai/api/v1/portfolio
```

**Responses**

- `201` — the created company (same shape as `GET /portfolio/{slug}`).
- `400` — validation failed (missing `vehicleSlug`, malformed values, etc).
- `404` — `vehicleSlug` doesn't exist, or it belongs to another organization (existence-mask).
- `409` — a company with the same slug already exists in this org.

---

### `GET /portfolio/{slug}`

Fetch a single portfolio company by slug.

```bash
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/phases
```

**Response `200`**

```json
{
  "id": "cmx...",
  "slug": "phases",
  "name": "Phases",
  "website": "https://phases.so",
  "sector": "Developer Tools",
  "stage": "pre-seed",
  "checkSize": 25000,
  "postMoneyValuation": 20000000,
  "investmentDate": "2025-11-26T00:00:00.000Z",
  "instrumentType": "SAFE",
  "status": "ACTIVE",
  "notes": "YC S25.",
  "logoUrl": null,
  "createdAt": "2025-11-26T19:00:00.000Z",
  "updatedAt": "2026-03-12T10:00:00.000Z"
}
```

---

### `PUT /portfolio/{slug}`

Upsert a portfolio company by slug. Full-record replace. Safe to retry — this is the recommended write verb for sync clients.

Body shape matches `POST /portfolio`. `slug` in the body, if present, must match the URL slug.

```bash
curl -X PUT \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Phases",
    "website": "https://phases.so",
    "sector": "Developer Tools",
    "stage": "pre-seed",
    "checkSize": 25000,
    "postMoneyValuation": 20000000,
    "investmentDate": "2025-11-26",
    "instrumentType": "SAFE",
    "status": "MARKED_UP",
    "notes": "Marked up after their seed round."
  }' \
  https://www.gpagent.ai/api/v1/portfolio/phases
```

**Response `200`** on update, `201` on create. Body is the resulting company.

---

### `PATCH /portfolio/{slug}`

Partial update. Only the fields you send are changed; everything else is left alone. Returns `404` if the slug does not exist.

```bash
curl -X PATCH \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "MARKED_UP", "notes": "Seed round announced."}' \
  https://www.gpagent.ai/api/v1/portfolio/phases
```

**Response `200`** — the updated company.

---

### `DELETE /portfolio/{slug}`

Soft-delete a portfolio company. The record is retained in the database with `deletedAt` set and excluded from future reads. A subsequent `PUT /portfolio/{slug}` will restore it.

```bash
curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/phases
```

**Response `204`** (no body).

---

## Using GPAgent as a backing store for an agent

If you're wiring this API into an agent (OpenClaw, Claude Code, a custom script), the practical rules are:

1. **Never cache.** Query the API at the moment you need the data. GPAgent is cheap and always current.
2. **Use `PUT` for writes.** It's the only verb safe to retry after a timeout.
3. **Pipe the curl output through `jq`** if you want to let the agent read specific fields without parsing the whole response.
4. **Persist the API key outside the agent's working directory.** For OpenClaw, it belongs in `~/.openclaw/openclaw.json` under `env.GPAGENT_API_KEY`.
5. **Add this file to your agent's context.** The doc is served at `/api-docs` — point your agent's extra-context list at it (for OpenClaw: `agents.defaults.memorySearch.extraPaths`).

---

---

## Deal Pipeline

Deals are org-scoped (not vehicle-scoped) since a pipeline spans all funds. Vehicle auth is still required to identify your organization — the resolved `organizationId` is used.

### `GET /deals`

List all active deals in your pipeline. Supports filtering, pagination.

**Query parameters**

| Param    | Type   | Notes                                                                                             |
| -------- | ------ | ------------------------------------------------------------------------------------------------- |
| `source` | enum   | `YC` \| `ANGELLIST` \| `REFERRAL` \| `INBOUND` \| `MANUAL`                                        |
| `stage`  | enum   | `DISCOVERED` \| `RESEARCHING` \| `PRE_MEETING` \| `MEETING` \| `TERMS` \| `COMMITTED` \| `PASSED` |
| `batch`  | string | Filter by `sourceDetail` (case-insensitive contains). E.g. `batch=W26`                            |
| `limit`  | number | Max records to return. Default `50`, max `200`.                                                   |
| `offset` | number | Pagination offset. Default `0`.                                                                   |

```bash
# All RESEARCHING deals from YC W26
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/deals?stage=RESEARCHING&batch=W26"
```

**Response `200`**

```json
{
  "total": 12,
  "limit": 50,
  "offset": 0,
  "deals": [
    {
      "id": "cma...",
      "slug": "raindrop",
      "companyName": "Raindrop",
      "website": "https://raindrop.dev",
      "description": "Sentry for AI agents.",
      "source": "YC",
      "sourceDetail": "YC W26",
      "stage": "RESEARCHING",
      "thesisFitScore": 5,
      "estimatedValuation": 20000000,
      "estimatedCheckSize": null,
      "sector": "AI/ML",
      "companyStage": "seed",
      "notes": "...",
      "logoUrl": null,
      "links": [],
      "scoutedByAgentId": "cma...",
      "createdAt": "2026-03-22T10:00:00.000Z",
      "updatedAt": "2026-03-22T10:00:00.000Z"
    }
  ]
}
```

---

### `POST /deals`

Create a new deal. Strict create — returns `409` if a deal with the same slug already exists for this org.

**Request body** (JSON)

| Field                | Type          | Required | Notes                                                                                                               |
| -------------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `companyName`        | string        | yes      |                                                                                                                     |
| `slug`               | string        | no       | Derived from `companyName` if omitted.                                                                              |
| `website`            | string (URL)  | no       |                                                                                                                     |
| `description`        | string        | no       |                                                                                                                     |
| `source`             | enum          | no       | Default `MANUAL`                                                                                                    |
| `sourceDetail`       | string        | no       | e.g. `YC W26`                                                                                                       |
| `stage`              | enum          | no       | Default `DISCOVERED`. One of `DISCOVERED`, `RESEARCHING`, `PRE_MEETING`, `MEETING`, `TERMS`, `COMMITTED`, `PASSED`. |
| `thesisFitScore`     | number (0–10) | no       |                                                                                                                     |
| `estimatedValuation` | number        | no       | USD                                                                                                                 |
| `estimatedCheckSize` | number        | no       | USD                                                                                                                 |
| `sector`             | string        | no       |                                                                                                                     |
| `companyStage`       | string        | no       | e.g. `pre-seed`, `seed`                                                                                             |
| `notes`              | string        | no       |                                                                                                                     |
| `logoUrl`            | string (URL)  | no       |                                                                                                                     |
| `links`              | array         | no       | `[{ type, label, url }]`                                                                                            |
| `scoutedByAgentId`   | string        | no       | Agent that sourced this deal                                                                                        |

**Response `201`** — the created deal.

---

### `GET /deals/{slug}`

Fetch a single deal by slug. Returns the deal plus all comments.

```bash
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/deals/raindrop
```

**Response `200`** — deal object with `comments` array appended:

```json
{
  "id": "cma...",
  "slug": "raindrop",
  "companyName": "Raindrop",
  "...",
  "comments": [
    {
      "id": "cmc...",
      "dealId": "cma...",
      "authorName": "Scout",
      "authorType": "agent",
      "content": "High thesis fit. Prioritize at Demo Day.",
      "metadata": {},
      "createdAt": "2026-03-22T10:05:00.000Z"
    }
  ]
}
```

---

### `PUT /deals/{slug}`

Upsert a deal by slug. Full-record replace. Safe to retry.

Body shape matches `POST /deals`. `slug` in the body, if present, must match the URL slug.

**Response `200`** on update, `201` on create.

---

### `PATCH /deals/{slug}`

Partial update. Send only the fields you want to change. Useful for stage transitions.

```bash
# Advance a deal to MEETING
curl -X PATCH \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"stage": "MEETING"}' \
  https://www.gpagent.ai/api/v1/deals/raindrop
```

**Response `200`** — the updated deal. `404` if slug not found.

---

### `DELETE /deals/{slug}`

Soft-delete a deal. Sets `deletedAt`; excluded from future reads. A subsequent `PUT` restores it.

**Response `204`** (no body).

---

### `POST /deals/{slug}/comments`

Add a comment to a deal.

**Request body** (JSON)

| Field        | Type   | Required | Notes                                    |
| ------------ | ------ | -------- | ---------------------------------------- |
| `authorName` | string | yes      | Display name                             |
| `authorType` | enum   | no       | `human` (default) \| `agent` \| `system` |
| `content`    | string | yes      |                                          |
| `metadata`   | object | no       | Arbitrary JSON. Default `{}`             |

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"authorName": "Scout", "authorType": "agent", "content": "Confirmed round is open."}' \
  https://www.gpagent.ai/api/v1/deals/raindrop/comments
```

**Response `201`** — the created comment.

---

## Scoring Criteria

Organization-level scoring rubric used by the Scout agent to evaluate deals.

### `GET /scoring-criteria`

Return the current scoring criteria for your organization.

```bash
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/scoring-criteria
```

**Response `200`**

```json
{
  "organizationId": "cma...",
  "organizationName": "Element 14 Capital",
  "criteria": {
    "domainExpertise": {
      "4": "Founder/market fit. Complementary team...",
      "3": "Some domain knowledge...",
      "2": "Limited background...",
      "1": "No relevant background..."
    },
    "product": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "fundraisability": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "market": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "execution": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "traction": { "4": "...", "3": "...", "2": "...", "1": "..." }
  }
}
```

If no custom criteria are set, returns the E14 Capital defaults.

---

### `PUT /scoring-criteria`

Full replace of the scoring criteria. All 6 categories and all 4 score levels are required.

```bash
curl -X PUT \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domainExpertise": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "product": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "fundraisability": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "market": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "execution": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "traction": { "4": "...", "3": "...", "2": "...", "1": "..." }
  }' \
  https://www.gpagent.ai/api/v1/scoring-criteria
```

**Response `200`** — the saved criteria (same shape as GET).

---

---

## Vehicles

Vehicles are the unit of fund accounting in GPAgent. One organization can own many vehicles (e.g. `fund-i`, `fund-ii`, an SPV, a personal pre-fund book). Every `PortfolioCompany` belongs to exactly one vehicle.

List your vehicles before calling `POST /portfolio` so you can pass the right `vehicleSlug`. Slugs are stable, human-readable, and unique within an organization.

### `GET /vehicles`

List all vehicles owned by organizations the caller has memberships in. Sorted by `createdAt` ascending (oldest first).

```bash
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/vehicles
```

**Response `200`**

```json
{
  "items": [
    {
      "id": "cmx...",
      "slug": "fund-i",
      "name": "Fund I",
      "type": "FUND",
      "description": "LP-backed Element 14 Capital Fund I",
      "organizationId": "cmy...",
      "companyCount": 10,
      "createdAt": "2024-06-01T00:00:00.000Z"
    },
    {
      "id": "cmz...",
      "slug": "pre-fund-i",
      "name": "Pre-Fund I",
      "type": "FUND",
      "description": "Personal angel investments before Fund I",
      "organizationId": "cmy...",
      "companyCount": 6,
      "createdAt": "2025-06-01T00:00:00.000Z"
    }
  ]
}
```

Returns an empty `items` array if the caller has no organization memberships.

---

## Portfolio Intelligence

These endpoints handle raw investor update storage, metric snapshots, customer logos, data gaps, and fund-level statistics. All are scoped to the caller's organization(s).

### `POST /portfolio/companies/{slug}/investor-updates`

Upload a raw investor update for a portfolio company.

**Idempotency:** if `sourceRef` is provided and already exists for this company, the existing row is returned with status `200` instead of creating a duplicate.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "receivedAt": "2026-01-15T10:00:00Z",
    "source": "email",
    "sourceRef": "gmail-thread-abc123",
    "fromAddress": "founder@startup.com",
    "subject": "Q4 2025 Investor Update",
    "rawBody": "Hi investors, here is our Q4 update..."
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates
```

**Response `201`** — created. **`200`** — existing row returned (idempotent `sourceRef` match).

---

### `GET /portfolio/companies/{slug}/investor-updates`

List investor updates for a company.

**Query params:** `needsExtraction=metrics|customers`, `limit` (default 50, max 200), `cursor` (id-based pagination).

**Response `200`**

```json
{ "items": [...], "nextCursor": "<id or null>" }
```

---

### `GET /portfolio/companies/{slug}/investor-updates/{id}`

Fetch a single investor update by id.

```bash
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates/update-id
```

---

### `POST /portfolio/companies/{slug}/metric-snapshots`

Append a metric snapshot for a portfolio company. Always creates a new row (append-only time series).

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "asOf": "2026-01-01T00:00:00Z",
    "reportedPeriod": "2025-Q4",
    "arrUsd": 1200000,
    "mrrUsd": 100000,
    "headcount": 18,
    "runwayMonths": 18
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/metric-snapshots
```

**Response `201`** — the created snapshot.

---

### `GET /portfolio/companies/{slug}/metric-snapshots`

Read the metric time series for a company. Returns in descending `asOf` order.

**Query params:** `from` (ISO date), `to` (ISO date), `limit` (default 100, max 500).

---

### `POST /portfolio/companies/{slug}/customers`

Upsert a customer logo. Matches on `(companyId, customerNameNormalized)` — if a customer with the same normalized name already exists it is updated; otherwise created.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customerName": "Acme Corporation",
    "tier": "F500",
    "status": "ACTIVE",
    "firstSignedAt": "2025-09-01T00:00:00Z"
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers
```

**Tiers:** `F100`, `F500`, `ENTERPRISE`, `MIDMARKET`, `SMB`, `GOV`, `EDU`, `OTHER`  
**Statuses:** `ACTIVE`, `PAUSED`, `CHURNED`, `PILOT`, `LOST_DEAL`

**Response `201`** (created) or **`200`** (updated).

---

### `GET /portfolio/companies/{slug}/customers`

List customers for a company.

**Query params:** `status` (filter), `tier` (filter).

---

### `POST /portfolio/companies/{slug}/customers/{id}/churn`

Mark a customer as churned. Idempotent — if already `CHURNED`, returns the current row without modification.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "churnedAt": "2026-03-15T00:00:00Z", "notes": "Went with competitor" }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers/cust-id/churn
```

---

### `POST /portfolio/companies/{slug}/data-gaps`

Create a data gap report for a company. Always creates a new row.

**Gap types:** `ATTACHMENT_UNREAD`, `LINK_UNREAD`, `METRIC_MISSING`, `CUSTOMER_AMBIGUOUS`, `PERIOD_UNCLEAR`

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "gapType": "METRIC_MISSING",
    "description": "ARR not found in Q4 update body",
    "suggestedField": "arrUsd"
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/data-gaps
```

---

### `GET /portfolio/data-gaps`

List data gaps across all companies in the caller's organizations.

**Query params:** `status` (default `OPEN`), `companyId` (optional filter), `limit` (default 50).

---

### `POST /portfolio/data-gaps/{id}/resolve`

Resolve a data gap with a provided value. **Side-effect endpoint, not just a status flip.** Resolving propagates the value to its destination in the data model:

| Gap type                           | Side-effect on resolve                                                                                                                                          |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `METRIC_MISSING`                   | Appends a `PortfolioCompanyMetricSnapshot` row with the resolved value in the column named by `suggestedField` (e.g. `metrics.arrUsd`).                         |
| `CUSTOMER_AMBIGUOUS`               | Upserts a `PortfolioCompanyCustomer` row from the structured payload `{customerName, tier, status?, dealValueAnnualUsd?, contractTerm?, notes?}`.               |
| `PERIOD_UNCLEAR`                   | Updates the source `PortfolioCompanyInvestorUpdate.reportedPeriod` field with the resolved string.                                                              |
| `ATTACHMENT_UNREAD`, `LINK_UNREAD` | Pasted content is saved on `resolvedValue`. The next weekly extraction run reads it. We do **not** auto-trigger LLM calls from this endpoint (cost discipline). |

Idempotent — resolving an already-resolved gap returns the current state without re-running side-effects.

Response: the resolved gap plus an `effects: [{kind, detail, ref?}]` array describing what changed in the data model.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "resolvedValue": 1200000, "notes": "Confirmed via follow-up call" }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/gap-id/resolve
```

---

### `POST /portfolio/data-gaps/{id}/dismiss`

Mark a data gap as `DISMISSED` ("not going to fill this in"). Distinct from `RESOLVED` (provides a value) and `DELETE` (removes the row). Idempotent. Body is optional.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "notes": "Founder confirmed metric not relevant" }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/gap-id/dismiss
```

---

### `POST /portfolio/data-gaps/bulk-resolve`

Resolve up to **100** data gaps in a single request. Per-item partial success: a bad item does not 4xx the batch.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resolutions": [
      { "gapId": "gap-1", "resolvedValue": 1200000 },
      { "gapId": "gap-2", "resolvedValue": "2026-Q1" }
    ]
  }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/bulk-resolve
```

Response:

```json
{
  "summary": { "resolved": 2, "alreadyResolved": 0, "notFound": 0, "invalid": 0, "total": 2 },
  "items": [
    { "gapId": "gap-1", "status": "resolved", "gap": {...}, "effects": [...] },
    { "gapId": "gap-2", "status": "resolved", "gap": {...}, "effects": [...] }
  ]
}
```

Per-item statuses: `resolved`, `already-resolved`, `not-found` (existence-mask: gap missing OR cross-org), `invalid` (validation error during the per-item write).

---

### `POST /portfolio/data-gaps/bulk-dismiss`

Dismiss up to **100** data gaps in a single request. Per-item partial success.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "gapIds": ["gap-1", "gap-2", "gap-3"], "notes": "All three resolved offline" }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/bulk-dismiss
```

Per-item statuses: `dismissed`, `already-dismissed`, `not-found`.

---

### `POST /portfolio/news`

Bulk-ingest portfolio company news items. Auto-publish thresholds depend on source tier and confidence; idempotent per URL.

**Org scoping (since 2026-05-07, Phase 4.5):** every item is scope-checked against the caller's organizations. If `companySlug` resolves to a company outside the caller's orgs, that item is rejected with `status: "rejected_wrong_org"` and the rest of the batch continues — partial success preserved (a single bad item must not 403 the other 99).

Response item statuses:

| Status                     | Meaning                                                                 |
| -------------------------- | ----------------------------------------------------------------------- |
| `published`                | New row created.                                                        |
| `updated`                  | Existing row updated (idempotent re-ingest).                            |
| `rejected_low_confidence`  | confidence below the source-tier threshold.                             |
| `rejected_no_mention`      | Tier-2 source with no in-text company mention.                          |
| `rejected_company_unknown` | No portfolio company with that slug.                                    |
| `rejected_wrong_org`       | The company exists but belongs to a different organization (Phase 4.5). |
| `rejected_invalid`         | Reserved for future use.                                                |

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{
      "companySlug": "phases",
      "url": "https://techcrunch.com/2026/01/10/phases-raises-series-a",
      "title": "Phases raises $5M Series A",
      "publishedAt": "2026-01-10T09:00:00Z",
      "confidence": 0.95
    }]
  }' \
  https://www.gpagent.ai/api/v1/portfolio/news
```

**Response `200`**

```json
{ "summary": { "published": 1, "updated": 0, "rejected": 0, "total": 1 }, "items": [...] }
```

---

### `POST /portfolio/news/maintenance`

Self-healing maintenance pass for the news feed. Idempotent and safe to call on any cadence. Designed for a daily cron.

**Org scoping (since 2026-05-07, Phase 4.5):** all three operations are filtered to news items whose company belongs to one of the caller's organizations. The filter is applied at the SQL layer (`companyId IN (orgCompanyIds)`). If the caller has no accessible companies, the endpoint short-circuits to zero stats and zero work.

What it does:

1. Re-validates URLs of items not validated in the last 7 days. 4xx responses hide the item; 5xx and network errors are treated as transient.
2. Hides items older than 365 days from `publishedAt` to keep the feed fresh.
3. Returns `materialNegative` items from the last 24 hours so a downstream cron can fan out alerts (Telegram, email, etc.).

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/news/maintenance
```

**Response `200`**

```json
{
  "ok": true,
  "stats": {
    "revalidated": 12,
    "autoHidden": 1,
    "archivedByAge": 0,
    "newNegatives": 0
  },
  "materialNegatives": []
}
```

---

### `POST /portfolio/stats/recompute`

Recompute and persist a `PortfolioStatsSnapshot` for a fund vehicle. Sums ARR across all portcos' latest metric snapshots, computes median YoY growth multiple, and counts F100/F500 customer logo coverage.

Accepts either **`vehicleSlug`** (preferred, matches the rest of v1) or **`vehicleId`** (deprecated cuid form, kept for backwards compat with pre-Phase-7b callers — notably the Phase 6 fund-intelligence cron). At least one is required; if both are provided, `vehicleSlug` wins. `400` if neither is provided.

```bash
# Preferred form (Phase 7b+):
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "vehicleSlug": "fund-i" }' \
  https://www.gpagent.ai/api/v1/portfolio/stats/recompute

# Legacy / deprecated cuid form (still works):
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "vehicleId": "<vehicle-cuid>" }' \
  https://www.gpagent.ai/api/v1/portfolio/stats/recompute
```

**Response `201`**

```json
{
  "id": "snap-1",
  "vehicleId": "v1",
  "portfolioArrUsd": 8500000,
  "portfolioArrYoyMultiple": 3.2,
  "enterpriseLogoCoverage": 0.6,
  "totalF500Customers": 6,
  "totalActiveCustomers": 10
}
```

---

### `GET /portfolio/stats/latest`

Return the most recent `PortfolioStatsSnapshot` for a vehicle.

**Query params:** `vehicleSlug` (preferred) **or** `vehicleId` (deprecated cuid form). At least one is required; if both are passed, slug wins.

```bash
# Preferred form (Phase 7b+):
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/portfolio/stats/latest?vehicleSlug=fund-i"

# Legacy / deprecated cuid form (still works):
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/portfolio/stats/latest?vehicleId=<vehicle-cuid>"
```

---

## Soft delete + restore (portfolio intelligence)

These endpoints let you remove and restore portfolio intelligence rows. List/detail GETs filter `deletedAt IS NOT NULL` by default; pass `?includeDeleted=true` to bypass.

### `DELETE /portfolio/companies/{slug}/investor-updates/{id}`

Soft-delete an investor update. Idempotent — already-deleted rows return `200` with the existing soft-deleted row.

Does NOT cascade to derived metric snapshots that referenced this update via `sourceUpdateId`. Snapshots are append-only and stand on their own evidence; the `sourceUpdateId` becomes a dangling pointer by design.

```bash
curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates/upd-id
```

**Response `200`** — the soft-deleted row.

---

### `POST /portfolio/companies/{slug}/investor-updates/{id}/restore`

Restore a soft-deleted investor update by clearing `deletedAt`. Idempotent.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates/upd-id/restore
```

**Response `200`** — the restored row.

---

### `DELETE /portfolio/companies/{slug}/metric-snapshots/{id}`

HARD delete a metric snapshot. Snapshots are derived/append-only data and recomputable from upstream sources, so we don't keep tombstones. Idempotent at the resource level (`404` once gone).

```bash
curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/metric-snapshots/snap-id
```

**Response `204`** (no body).

---

### `DELETE /portfolio/companies/{slug}/customers/{id}`

Soft-delete a customer. Idempotent. Distinct from `CHURNED` status: `CHURNED` represents a real-world churn event; `deletedAt` is data hygiene (wrong row, accidental import).

```bash
curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers/cust-id
```

**Response `200`** — the soft-deleted row.

---

### `POST /portfolio/companies/{slug}/customers/{id}/restore`

Restore a soft-deleted customer. Idempotent.

```bash
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers/cust-id/restore
```

**Response `200`** — the restored row.

---

### `DELETE /portfolio/data-gaps/{id}`

HARD delete a data gap. Idempotent at the resource level.

```bash
curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/gap-id
```

**Response `204`** (no body).

---

### `GET /portfolio/news/{id}`

Fetch a single news item by id. Soft-deleted items are excluded unless you pass `?includeDeleted=true`.

**Org scoping (since Phase 4.5):** if the news item belongs to a portfolio company outside the caller's organizations, the endpoint returns `404` (not `403`) so cross-org existence isn't leaked via id-iteration.

```bash
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/news/news-id
```

---

### `DELETE /portfolio/news/{id}`

Soft-delete a news item. Idempotent. Distinct from `hidden`: `hidden` de-prioritizes an item in the user-facing feed; `deletedAt` removes it from the API surface entirely.

**Org scoping (since Phase 4.5):** cross-org access returns `404`, same convention as the GET endpoint.

```bash
curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/news/news-id
```

**Response `200`** — the soft-deleted row.

---

### `DELETE /portfolio/stats/{id}`

HARD delete a fund stats snapshot. Stats are derived from current portfolio state and recomputable at any time via `POST /portfolio/stats/recompute`. Idempotent at the resource level.

```bash
curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/stats/snap-id
```

**Response `204`** (no body).

---

## Changelog

- **2026-05-08** — Phase 7b-0: `/portfolio/stats/recompute` and `/portfolio/stats/latest` now accept `vehicleSlug` in addition to `vehicleId`. Slug is preferred and matches the rest of v1; cuid form is documented as deprecated but still works (the Phase 6 fund-intelligence cron uses it). At least one identifier is required; if both are provided, slug wins. **Non-breaking** — the previous cuid-only contract is preserved.
- **2026-05-08** — **Breaking:** Phase 4.7: `POST /portfolio` requires explicit `vehicleSlug`. New `GET /vehicles` endpoint to list available slugs. Motivated by a 2026-05-08 incident where six personal pre-fund investments were created under the LP-backed Fund I vehicle because the endpoint silently used the caller's first vehicle. Required fields enforce a decision; defaults invite drift. See "Breaking changes" above.
- **2026-04-16** — Added `PRE_MEETING` to `DealStage` enum (sits between `RESEARCHING` and `MEETING`); agents own `DISCOVERED`/`RESEARCHING`, GPs own `PRE_MEETING` onward.
- **2026-04-16** — `GET/POST /deals`, `GET/PUT/PATCH/DELETE /deals/{slug}`, `POST /deals/{slug}/comments`, `GET/PUT /scoring-criteria`.
- **2026-05-08** — **Breaking:** Phase 4.6: standardize 404-everywhere for cross-org access. Every portfolio-intel endpoint that previously returned `403` for cross-org access now returns `404` with a response body indistinguishable from a genuine missing resource. See "Authentication & Authorization" and "Breaking changes" above. Affects: every `/portfolio/*` endpoint with a slug/id parameter; the news endpoints already followed this convention since Phase 4.5.
- **2026-05-07** — Phase 4.5: org-scope every news endpoint. `POST /portfolio/news` rejects wrong-org items per-item (new `rejected_wrong_org` status) and continues the batch. `POST /portfolio/news/maintenance` filters every query by `companyId IN (caller's orgs)`. `GET`/`DELETE /portfolio/news/{id}` return `404` for cross-org access (existence-mask).
- **2026-05-07** — Phase 4: DELETE + restore endpoints across portfolio intelligence surface. Soft-delete (with `?includeDeleted=true` admin override) for investor updates, customers, news items; hard-delete for metric snapshots, data gaps, fund stats snapshots.
- **2026-05-07** — Phase 2: Portfolio intelligence endpoints (investor updates, metric snapshots, customers, data gaps, news ingest, fund stats).
- **2026-04-10** — v1 launch: `GET/POST /portfolio`, `GET/PUT/PATCH/DELETE /portfolio/{slug}`. Deal pipeline and Updates endpoints shipping next.
