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) 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 apexgpagent.aiissues a307redirect towww.gpagent.ai, and most HTTP clients (curl, fetch, reqwest) drop theAuthorizationheader on cross-host redirects as a security measure. Pointing atwww.gpagent.aidirectly avoids an unauthenticated retry and the resulting401.
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:
{ "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 /portfolioandGET/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 return404(existence-mask). - 2026-05-08 (Phase 4.7):
POST /portfolionow requires an explicitvehicleSlugin 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 validvehicleSlugvalues via the newGET /vehiclesendpoint. Requests missingvehicleSlugnow return400; requests with a slug that doesn't exist (or belongs to another organization) return404(existence-mask). - 2026-05-08 (Phase 4.6): endpoints that previously returned
403 FORBIDDENfor cross-org access now return404 NOT_FOUNDinstead. 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 the403status — it can't happen any more for cross-org access.403is 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.
# 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
{
"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):
vehicleSlugis 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 viaGET /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 |
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 asGET /portfolio/{slug}).400— validation failed (missingvehicleSlug, malformed values, etc).404—vehicleSlugdoesn'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.
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
https://www.gpagent.ai/api/v1/portfolio/phases
Response 200
{
"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.
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.
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.
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:
- Never cache. Query the API at the moment you need the data. GPAgent is cheap and always current.
- Use
PUTfor writes. It's the only verb safe to retry after a timeout. - Pipe the curl output through
jqif you want to let the agent read specific fields without parsing the whole response. - Persist the API key outside the agent's working directory. For OpenClaw, it belongs in
~/.openclaw/openclaw.jsonunderenv.GPAGENT_API_KEY. - 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. |
# 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
{
"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.
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
https://www.gpagent.ai/api/v1/deals/raindrop
Response 200 — deal object with comments array appended:
{
"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.
# 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 {} |
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.
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
https://www.gpagent.ai/api/v1/scoring-criteria
Response 200
{
"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.
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).
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
https://www.gpagent.ai/api/v1/vehicles
Response 200
{
"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.
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
{ "items": [...], "nextCursor": "<id or null>" }
GET /portfolio/companies/{slug}/investor-updates/{id}
Fetch a single investor update by id.
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).
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.
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.
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
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.
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.
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.
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:
{
"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.
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. |
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
{ "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:
- 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.
- Hides items older than 365 days from
publishedAtto keep the feed fresh. - Returns
materialNegativeitems from the last 24 hours so a downstream cron can fan out alerts (Telegram, email, etc.).
curl -X POST \
-H "Authorization: Bearer $GPAGENT_API_KEY" \
https://www.gpagent.ai/api/v1/portfolio/news/maintenance
Response 200
{
"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.
# 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
{
"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.
# 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.
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.
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).
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).
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.
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.
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.
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.
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.
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/recomputeand/portfolio/stats/latestnow acceptvehicleSlugin addition tovehicleId. 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 /portfoliorequires explicitvehicleSlug. NewGET /vehiclesendpoint 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_MEETINGtoDealStageenum (sits betweenRESEARCHINGandMEETING); agents ownDISCOVERED/RESEARCHING, GPs ownPRE_MEETINGonward. - 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
403for cross-org access now returns404with 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/newsrejects wrong-org items per-item (newrejected_wrong_orgstatus) and continues the batch.POST /portfolio/news/maintenancefilters every query bycompanyId IN (caller's orgs).GET/DELETE /portfolio/news/{id}return404for cross-org access (existence-mask). - 2026-05-07 — Phase 4: DELETE + restore endpoints across portfolio intelligence surface. Soft-delete (with
?includeDeleted=trueadmin 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.