# Invisible Cap Table API Reference

Base URL: `https://api.invisible.app`

JSON naming: all request/response fields use **camelCase**. Enum values use **camelCase** (e.g., `"common"`, `"convertibleNote"`).

---

## 1. Authentication

All endpoints except `/health`, `/api/chat/public`, and OAuth metadata require authentication.

### Method A: API Key

Get a key by asking the chat assistant at [invisible.app](https://invisible.app) (`generate_api_key`), or from a signed-in MCP connection. A key can't be created with another API key.

```
Authorization: Bearer icap_YOUR_KEY
```

- **Scope:** one organization (the one active when it was made). No additional headers needed. A key can't switch to another organization (`set_active_organization` and conversations from another org are refused), even one its owner belongs to; make a key there instead.
- **Access:** exactly its owner's role and visibility in that organization, checked on every request. It stops working if the owner leaves the organization. Every key belongs to a person; keys without one are refused.
- **Expiry:** one year by default; `expiresInDays` (1 to 3650) or `neverExpires: true` when creating.
- **Read-only:** `readOnly: true` makes a key that can only read: REST `GET` requests, and read tools over MCP or chat. Anything else gets `403` / an error.
- **Managing keys:** `get_account_data` (view `api_keys`) lists name, owner, read-only, created, last used, expiry and status (`active`, `expired`, `revoked`, or `owner_removed`: its owner left the organization, so it doesn't work, but would again if they rejoined; revoke it). `revoke_api_key` (`keyId`) stops a key immediately. Everyone manages their own keys; owners and admins see and revoke any key in the organization. The key itself is shown only when created; only a hash is stored.

### Method B: JWT (Cognito) or a connector token

The web app sends its Cognito ID token; an MCP connector sends the `ioat_` access token from `/token` (see MCP Server, Tokens). Both act as the signed-in person.

```
Authorization: Bearer <jwt_token>
X-Organization-Id: <org_uuid>
```

`POST /api/chat` and `POST /api/chat/public` take an optional `X-Invisible-Product` header, `cap` (default) or `sign`, choosing which product's assistant answers; sign.invisible.app sends `sign`. The Sign assistant offers only Invisible Sign, account and support tools. Using a product turns it on for the organization.

`X-Organization-Id` is required for all endpoints except `/api/me`, `/api/organizations`, `/api/invites/*`, `/api/org-invites/redeem`, `/api/chat`, and `/api/conversations`. Endpoints under `/api/public/*` need no authentication at all.

### Role-Based Access

| Role | Read cap table | Write cap table | Manage members |
|------|---------------|-----------------|----------------|
| `owner` | Full | Yes | Yes |
| `admin` | Full | Yes | Yes |
| `member` | Full | Yes | No |
| `viewer` | Full (if `canViewFullCapTable=true`) or own holdings only | No | No |
| `employee` | Own holdings only | No | No |

Write endpoints (`POST`, `PUT`, `DELETE` on cap table data) are open to `owner`, `admin` and `member` only, and return `403` for every other role, including `viewer`, `employee` and an outside 409A `reviewer`. Exercising options and deleting grants are `owner`/`admin` only. Every id in a write (stakeholder, plan, security class, investor group) must belong to the organization; another organization's id is refused.

**Visibility modifiers** for restricted viewers (viewer without full cap table, employee):
- **Manager hierarchy**: If the user's linked stakeholder has reports (via `manager_id`), they see the full subtree of reports' equity data.
- **Department access**: Admins can grant users view access to all equity data in specific departments via `department_access`.
- The user sees the union of: own holdings + report tree + department access.

---

## 2. Cap Table (Read)

### GET /api/cap-table

Full cap table with all stakeholders, summary, security classes, and investor groups.

**Auth:** Required. Restricted viewers see only their own holdings.

```bash
curl -s https://api.invisible.app/api/cap-table \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "stakeholders": [
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000001",
      "name": "Sarah Chen",
      "type": "individual",
      "commonShares": 4000000,
      "preferredShares": 0,
      "preferredSeriesDetail": [],
      "optionShares": 0,
      "fullyDilutedShares": 4000000,
      "ownershipPct": 40.0
    },
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000002",
      "name": "Sequoia Capital",
      "type": "entity",
      "commonShares": 0,
      "preferredShares": 2000000,
      "preferredSeriesDetail": [
        { "series": "Series A Preferred", "shares": 2000000, "asConvertedShares": 2000000 }
      ],
      "optionShares": 0,
      "fullyDilutedShares": 2000000,
      "ownershipPct": 20.0
    }
  ],
  "summary": {
    "totalCommon": 6000000,
    "totalPreferred": 2000000,
    "totalOptionsOutstanding": 500000,
    "totalFullyDiluted": 10000000,
    "optionPoolAuthorized": 1000000,
    "optionPoolAvailable": 500000
  },
  "securityClasses": [
    {
      "id": "b2c3d4e5-0001-4000-8000-000000000001",
      "name": "Common Stock",
      "type": "common",
      "authorizedShares": 8000000,
      "outstandingShares": 6000000,
      "originalIssuePrice": null,
      "liquidationPreference": null,
      "conversionRatio": 1.0,
      "isParticipating": false,
      "participationCap": null,
      "seniorityRank": null
    },
    {
      "id": "b2c3d4e5-0001-4000-8000-000000000002",
      "name": "Series A Preferred",
      "type": "preferred",
      "authorizedShares": 3000000,
      "outstandingShares": 2000000,
      "originalIssuePrice": 1.50,
      "liquidationPreference": 1.0,
      "conversionRatio": 1.0,
      "isParticipating": false,
      "participationCap": null,
      "seniorityRank": 1
    }
  ],
  "investorGroups": [
    {
      "id": "c3d4e5f6-0001-4000-8000-000000000001",
      "name": "Series A Investors",
      "totalShares": 2000000,
      "ownershipPct": 20.0,
      "memberIds": ["a1b2c3d4-0001-4000-8000-000000000002"]
    }
  ]
}
```

---

### GET /api/stakeholders

List all stakeholders. Supports search.

**Auth:** Required. Restricted viewers see only themselves.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `search` | string | Part of the legal name, display name or email, or an exact employee ID |

```bash
curl -s "https://api.invisible.app/api/stakeholders?search=chen" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "stakeholders": [
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000001",
      "name": "Sarah Chen",
      "type": "individual",
      "email": "sarah@acme.com",
      "jobTitle": "CEO",
      "department": "Executive",
      "employeeId": "EMP-001",
      "managerId": null,
      "managerName": null,
      "isActive": true
    }
  ]
}
```

---

### GET /api/stakeholders/{id}

Detailed view of a single stakeholder: direct holdings, transactions, option grants,
and beneficial ownership in both directions.

Equity is often held indirectly, through an LLC or a fund rather than in someone's
own name. `beneficialInterests` lists entities this stakeholder holds an interest
in; `beneficialOwners` is populated when this stakeholder is itself an entity and
lists who holds interests in it. A person's real position is their direct holdings
plus their beneficial interests, so reporting `holdings` alone can understate what
they own.

`legalEntity` is the company in a group that employs the person (it splits stock comp expense by entity). The mailing address (`addressLine1`, `addressLine2`, `city`, `region`, `postalCode`, `country` as a two-letter code) is used on Form 3921 and mailed notices. `tinLast4`, the last four digits of their SSN, is the truncated TIN on their Form 3921; like the address, it's shown only to owners, admins, members, full-access viewers and the person themselves. `eDeliveryConsentAt` is when the person consented to electronic delivery of tax statements and equity documents (null: statements go on paper); see `/api/me/edelivery-consent`.

**Auth:** Required. Restricted viewers can only access their own linked stakeholder, and people they manage; they see a mailing address only on their own record, and no beneficial ownership. Mailing addresses are shown to owners, admins, members and full-access viewers, and to the person themselves; not to 409A reviewers.

```bash
curl -s https://api.invisible.app/api/stakeholders/a1b2c3d4-0001-4000-8000-000000000001 \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "stakeholder": {
    "id": "a1b2c3d4-0001-4000-8000-000000000001",
    "organizationId": "d4e5f6a7-0001-4000-8000-000000000001",
    "name": "Sarah Chen",
    "type": "individual",
    "email": "sarah@acme.com",
    "jobTitle": "CEO",
    "department": "Executive",
    "employeeId": "EMP-001",
    "userId": null,
    "isActive": true,
    "createdAt": "2024-01-15T00:00:00Z",
    "updatedAt": "2024-01-15T00:00:00Z",
    "legalEntity": "Acme Inc.",
    "addressLine1": "1 Main St",
    "addressLine2": null,
    "city": "Boston",
    "region": "MA",
    "postalCode": "02110",
    "country": "US"
  },
  "eDeliveryConsentAt": "2026-10-14T15:02:11Z",
  "eDeliveryDisclosureVersion": "2026-10",
  "holdings": [
    { "securityClass": "Common Stock", "shares": 4000000, "pctOwnership": 40.0 }
  ],
  "transactions": [
    {
      "id": "e5f6a7b8-0001-4000-8000-000000000001",
      "securityId": "CS-001",
      "type": "issuance",
      "shares": 4000000,
      "pricePerShare": 0.001,
      "date": "2024-01-15T00:00:00Z",
      "notes": "Founder shares"
    }
  ],
  "optionGrants": [],
  "beneficialInterests": [
    {
      "id": "b7c8d9e0-0001-4000-8000-000000000001",
      "entityStakeholderId": "a1b2c3d4-0009-4000-8000-000000000009",
      "entityName": "Acme Holdings LLC",
      "ownerStakeholderId": "a1b2c3d4-0001-4000-8000-000000000001",
      "ownerName": "Sarah Chen",
      "ownershipPct": 0.9107,
      "sharesEquivalent": 54642,
      "notes": "From LLC cap table (conversion to Inc 01.23.23)"
    }
  ],
  "beneficialOwners": []
}
```

---

### GET /api/security-classes

List all security classes for the organization.

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/security-classes \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "classes": [
    {
      "id": "b2c3d4e5-0001-4000-8000-000000000001",
      "name": "Common Stock",
      "type": "common",
      "authorizedShares": 8000000,
      "outstandingShares": 6000000,
      "originalIssuePrice": null,
      "liquidationPreference": null,
      "conversionRatio": 1.0,
      "isParticipating": false,
      "participationCap": null,
      "seniorityRank": null
    }
  ]
}
```

---

### GET /api/equity-plans

List all equity incentive plans.

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/equity-plans \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "plans": [
    {
      "id": "f6a7b8c9-0001-4000-8000-000000000001",
      "organizationId": "d4e5f6a7-0001-4000-8000-000000000001",
      "name": "2024 Equity Incentive Plan",
      "year": 2024,
      "authorizedShares": 1000000,
      "createdAt": "2024-01-15T00:00:00Z",
      "sharesOutstanding": 410000,
      "sharesExercised": 25000,
      "sharesAvailable": 590000,
      "postTerminationExerciseDays": 90
    }
  ]
}
```

`sharesOutstanding` is net of cancellations (a partially cancelled grant returns only the cancelled portion); exercised options stay counted as consumed. `sharesAvailable` = authorized − outstanding.

---

## 3. Option Grants

### GET /api/option-grants

List option grants with optional filters.

**Auth:** Required. Restricted viewers see only their own grants.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `stakeholderId` | uuid | Filter by stakeholder |
| `department` | string | Filter by department |
| `status` | string | Comma-separated. Values: `proposed`, `pendingApproval`, `approved`, `active`, `fullyVested`, `exercised`, `partiallyExercised`, `cancelled`, `expired` |
| `planName` | string | Filter by equity plan name |
| `asOfDate` | string | Calculate vesting as of this date (`YYYY-MM-DD`) |

```bash
curl -s "https://api.invisible.app/api/option-grants?status=active,fullyVested&department=Engineering" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "grants": [
    {
      "id": "11111111-0001-4000-8000-000000000001",
      "employeeName": "James Park",
      "jobTitle": "Senior Engineer",
      "department": "Engineering",
      "grantNumber": "OPT-2024-001",
      "optionType": "iso",
      "grantDate": "2024-03-01T00:00:00Z",
      "vestingStartDate": "2024-03-01T00:00:00Z",
      "exercisePrice": 0.50,
      "sharesGranted": 50000,
      "sharesVested": 12500,
      "sharesUnvested": 37500,
      "sharesExercised": 0,
      "sharesCancelled": 0,
      "sharesExercisable": 12500,
      "expirationDate": "2034-03-01T00:00:00Z",
      "status": "active",
      "category": "newHire",
      "notes": null,
      "planName": "2024 Equity Incentive Plan",
      "vestingScheduleId": "a1b2c3d4-0001-4000-8000-000000000001",
      "vestingScheduleName": "4-year monthly, 1-year cliff",
      "vestingFrequency": "monthly",
      "vestingTotalMonths": 48,
      "vestingCliffMonths": 12,
      "terminatedAt": null,
      "vestingEndedAt": null,
      "exerciseDeadline": null,
      "lapsed": false
    }
  ]
}
```

`exerciseDeadline` is the earlier of the option's expiration and, for a terminated holder, the post-termination deadline (explicit date, or termination date + the grant's window override or the plan's `postTerminationExerciseDays`). After it passes, `lapsed` is true, `sharesExercisable` is 0, and the unexercised options no longer count as outstanding or against the pool. Vesting stops at `vestingEndedAt` when it is set (vesting stopped on a different day than termination, such as garden leave), otherwise at `terminatedAt`.

Cancelled shares (e.g. the unvested remainder forfeited at termination) come out of the unvested portion: vested is capped at granted minus cancelled, and unvested is what remains of that. Status filters and all enum inputs accept `snake_case` (`partially_exercised`), `camelCase`, or `PascalCase`; tool output uses `snake_case`.

Vested shares honor the schedule's frequency: `monthly`, `quarterly`, and `annually` schedules vest in whole tranches (elapsed months are rounded down to the last completed tranche), and the cliff tranche vests in full once reached.

---

### POST /api/option-grants

Create an option grant. Uses the **preview/confirm pattern**: first call with `confirm: false` to see impact, then call with `confirm: true` to commit.

**Auth:** Required. `owner`, `admin`, or `member` role.

**Enum values:**

- `optionType`: `iso`, `nqso`, `rsu`, `rsa` (rsa = restricted stock award: actual shares issued at grant, distinct from rsu)
- `status`: `proposed`, `pendingApproval`, `approved`, `active`, `fullyVested`, `exercised`, `partiallyExercised`, `cancelled`, `expired`
- `category`: `newHire`, `promotion`, `retention`, `performance`, `refresh`, `advisory`, `other`

#### Step 1: Preview (confirm=false)

```bash
curl -s -X POST https://api.invisible.app/api/option-grants \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
    "equityPlanId": "f6a7b8c9-0001-4000-8000-000000000001",
    "optionType": "iso",
    "sharesGranted": 50000,
    "exercisePrice": 0.50,
    "grantDate": "2024-03-01",
    "vestingStartDate": "2024-03-01",
    "expirationDate": "2034-03-01",
    "vestingScheduleId": "22222222-0001-4000-8000-000000000001",
    "status": "active",
    "category": "newHire",
    "grantNumber": "OPT-2024-001",
    "notes": "New hire grant for Senior Engineer",
    "confirm": false
  }'
```

**Preview response:**

```json
{
  "preview": {
    "grantDetail": {
      "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
      "equityPlanId": "f6a7b8c9-0001-4000-8000-000000000001",
      "optionType": "iso",
      "sharesGranted": 50000,
      "exercisePrice": 0.50,
      "grantDate": "2024-03-01",
      "vestingStartDate": "2024-03-01",
      "expirationDate": "2034-03-01",
      "vestingScheduleId": "22222222-0001-4000-8000-000000000001",
      "status": "active",
      "category": "newHire",
      "grantNumber": "OPT-2024-001",
      "notes": "New hire grant for Senior Engineer",
      "confirm": false
    },
    "poolImpact": {
      "availableBefore": 500000,
      "availableAfter": 450000,
      "pctPoolUsed": 55.0
    },
    "validationWarnings": []
  }
}
```

#### Step 2: Commit (confirm=true)

Same request body with `"confirm": true`.

```bash
curl -s -X POST https://api.invisible.app/api/option-grants \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
    "equityPlanId": "f6a7b8c9-0001-4000-8000-000000000001",
    "optionType": "iso",
    "sharesGranted": 50000,
    "exercisePrice": 0.50,
    "grantDate": "2024-03-01",
    "vestingStartDate": "2024-03-01",
    "expirationDate": "2034-03-01",
    "vestingScheduleId": "22222222-0001-4000-8000-000000000001",
    "status": "active",
    "category": "newHire",
    "grantNumber": "OPT-2024-001",
    "confirm": true
  }'
```

**Commit response:**

```json
{
  "grantId": "11111111-0001-4000-8000-000000000001",
  "preview": {
    "grantDetail": { "..." : "same as above" },
    "poolImpact": {
      "availableBefore": 500000,
      "availableAfter": 450000,
      "pctPoolUsed": 55.0
    },
    "validationWarnings": []
  }
}
```

---

### PUT /api/option-grants

Update an existing option grant. Only provided fields are modified.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X PUT https://api.invisible.app/api/option-grants \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "grantId": "11111111-0001-4000-8000-000000000001",
    "sharesGranted": 60000,
    "exercisePrice": 0.75,
    "category": "promotion",
    "notes": "Adjusted after promotion to Staff Engineer",
    "status": "active"
  }'
```

`optionType` (`iso` or `nqso`) corrects a grant entered as the wrong kind of option. It needs the `owner` or `admin` role (`403` otherwise) and writes an audit log entry with the old and new type. An RSU or RSA can't be retyped (`400`). Asking for the type a grant already has is a no-op.

**Response:**

```json
{ "updated": true }
```

Returns `404` if grant not found.

The MCP tool `update_option_grant` takes the same fields except `status` (use `update_grant_status`, which previews first). Tools on the hosted MCP connector (`/mcp`) refuse an argument they don't declare, with an error naming the ones they do take, instead of ignoring it.

---

### DELETE /api/option-grants/{id}

Delete an option grant.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X DELETE https://api.invisible.app/api/option-grants/11111111-0001-4000-8000-000000000001 \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{ "deleted": true }
```

Returns `404` if grant not found.

---

### POST /api/option-grants/exercise

Exercise vested options. Uses **preview/confirm pattern**.

**Auth:** Required. `owner`, `admin`, or `member` role.

#### Preview

```bash
curl -s -X POST https://api.invisible.app/api/option-grants/exercise \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "grantId": "11111111-0001-4000-8000-000000000001",
    "sharesToExercise": 10000,
    "exerciseDate": "2025-06-15",
    "notes": "Partial exercise",
    "confirm": false
  }'
```

**Preview response:**

```json
{
  "preview": {
    "grantId": "11111111-0001-4000-8000-000000000001",
    "sharesGranted": 50000,
    "sharesVested": 12500,
    "sharesPreviouslyExercised": 0,
    "sharesExercisable": 12500,
    "sharesToExercise": 10000,
    "sharesRemainingAfter": 2500,
    "newStatus": "partiallyExercised",
    "validationWarnings": []
  }
}
```

#### Commit

Same body with `"confirm": true`.

**Commit response:**

```json
{
  "exerciseId": "33333333-0001-4000-8000-000000000001",
  "preview": { "...": "same as above" }
}
```

---

### POST /api/grant-status

Batch-update the status of one or more grants. Uses **preview/confirm pattern**.

**Auth:** Required. `owner`, `admin`, or `member` role.

#### Preview

```bash
curl -s -X POST https://api.invisible.app/api/grant-status \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "grantIds": [
      "11111111-0001-4000-8000-000000000001",
      "11111111-0001-4000-8000-000000000002"
    ],
    "newStatus": "approved",
    "notes": "Board approved Q1 grants",
    "confirm": false
  }'
```

**Preview response:**

```json
{
  "grantsAffected": 2,
  "confirmRequired": true
}
```

#### Commit

```bash
curl -s -X POST https://api.invisible.app/api/grant-status \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "grantIds": [
      "11111111-0001-4000-8000-000000000001",
      "11111111-0001-4000-8000-000000000002"
    ],
    "newStatus": "approved",
    "notes": "Board approved Q1 grants",
    "confirm": true
  }'
```

**Commit response:**

```json
{
  "success": true,
  "grantsAffected": 2
}
```

---

### GET /api/vesting-schedules

List all vesting schedule templates.

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/vesting-schedules \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "schedules": [
    {
      "id": "22222222-0001-4000-8000-000000000001",
      "name": "Standard 4-year with 1-year cliff",
      "totalMonths": 48,
      "cliffMonths": 12,
      "frequency": "monthly",
      "createdAt": "2024-01-15T00:00:00Z"
    }
  ]
}
```

---

### POST /api/vesting-schedules

Create a new vesting schedule template.

**Auth:** Required. `owner`, `admin`, or `member` role.

**Enum values for `frequency`:** `monthly`, `quarterly`, `annually`

```bash
curl -s -X POST https://api.invisible.app/api/vesting-schedules \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "3-year quarterly with 6-month cliff",
    "totalMonths": 36,
    "cliffMonths": 6,
    "frequency": "quarterly"
  }'
```

**Response:**

```json
{ "vestingScheduleId": "22222222-0001-4000-8000-000000000002" }
```

---

## Grant Acceptance

### POST /api/grants/{grantId}/accept

Accepts a stock option grant. The grant must belong to a stakeholder linked to the current user. Records acceptance with timestamp.

**Auth:** Required. Any authenticated user (validates grant ownership).

```bash
curl -s -X POST https://api.invisible.app/api/grants/11111111-0001-4000-8000-000000000001/accept \
  -H "Authorization: Bearer <jwt_token>" \
  -H "X-Organization-Id: d4e5f6a7-0001-4000-8000-000000000001"
```

**Response:**

```json
{
  "accepted": true,
  "grantNumber": "511",
  "sharesGranted": 4000,
  "exercisePrice": 2.80,
  "message": "Grant #511 accepted..."
}
```

---

### GET /api/me/edelivery-consent

The caller's own consent to electronic delivery of tax statements (Form 3921) and equity documents: the disclosure text and version to show before they consent, and, for each equity record linked to their account, whether consent is on file. Without consent, tax statements go on paper.

**Auth:** Required. Any role; only the caller's own records.

```bash
curl -s https://api.invisible.app/api/me/edelivery-consent -H "Authorization: Bearer icap_YOUR_KEY"
```

```json
{
  "disclosureVersion": "2026-10",
  "disclosure": "Consent to electronic delivery. By consenting, you agree that ...",
  "records": [
    { "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003", "stakeholderName": "James Park",
      "consentedAt": null, "disclosureVersion": null, "affirmationName": null, "current": false }
  ]
}
```

### POST /api/me/edelivery-consent

Gives (`"consent": true`, with `affirmationName`, the full legal name the person typed after reading the disclosure) or withdraws (`"consent": false`) the caller's consent, for every equity record linked to them in the organization. Repeating either is harmless. Withdrawing keeps the history and applies to statements furnished afterwards. Returns `400` when consenting without a name or when the account has no linked record.

```bash
curl -s -X POST https://api.invisible.app/api/me/edelivery-consent \
  -H "Authorization: Bearer icap_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{ "consent": true, "affirmationName": "James Park" }'
```

```json
{ "ok": true, "consented": true, "message": "Consent to electronic delivery recorded for James Park (disclosure 2026-10). ..." }
```

Chat and MCP: `get_account_data` (view `edelivery_consent`) and `set_edelivery_consent`.

---

### GET /api/grants/pending

Returns active grants pending acceptance for the current user.

**Auth:** Required. Any authenticated user.

```bash
curl -s https://api.invisible.app/api/grants/pending \
  -H "Authorization: Bearer <jwt_token>" \
  -H "X-Organization-Id: d4e5f6a7-0001-4000-8000-000000000001"
```

**Response:**

```json
{
  "pendingGrants": [
    {
      "grantId": "11111111-0001-4000-8000-000000000001",
      "grantNumber": "511",
      "sharesGranted": 4000,
      "exercisePrice": 2.80,
      "grantDate": "2026-03-02",
      "expirationDate": "2036-03-02",
      "planName": "2019 Equity Incentive Plan",
      "totalMonths": 48,
      "cliffMonths": 12
    }
  ]
}
```

---

### GET /api/grants/pending/all

Lists all employees with pending grants (admin summary view).

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s https://api.invisible.app/api/grants/pending/all \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "employees": [
    {
      "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
      "stakeholderName": "Grant Kitching",
      "email": "grant@example.com",
      "grantCount": 2,
      "totalShares": 6500
    }
  ]
}
```

---

### POST /api/grants/pending/notify

Sends email notifications to employees with pending grants. Optionally target a single stakeholder.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/grants/pending/notify \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003"
  }'
```

Omit `stakeholderId` to notify all employees with pending grants. People with an account get a reminder to accept; people without one get the invite that creates their account and links their grants. Sends count toward the organization's daily sending limit; anyone past it is held back (`overLimit`) until tomorrow.

**Response:**

```json
{
  "sent": 15,
  "skipped": 3,
  "overLimit": 0,
  "message": "15 existing user(s) notified to accept; ..."
}
```

---

## Option Exercise

Employees exercise vested options via a signed **Notice of Exercise**; an admin records it into the cap table once payment (shares × strike) is received offline. Submitting a notice does not change holdings; recording it issues the shares as common stock. Only vested shares are exercisable (no early exercise).

### GET /api/grants/exercisable

Returns the current user's grants with exercisable share counts (vested − already-exercised − pending-notice shares).

**Auth:** Required. Any authenticated user (their own grants only).

```bash
curl -s https://api.invisible.app/api/grants/exercisable \
  -H "Authorization: Bearer <jwt_token>"
```

**Response:**

```json
{
  "exercisableGrants": [
    {
      "grantId": "11111111-0001-4000-8000-000000000001",
      "grantNumber": "511",
      "optionType": "Iso",
      "planName": "2019 Equity Incentive Plan",
      "sharesGranted": 4000,
      "exercisePrice": 2.80,
      "grantDate": "2026-03-02",
      "expirationDate": "2036-03-02",
      "sharesVested": 2000,
      "sharesExercised": 0,
      "sharesPendingNotice": 0,
      "sharesExercisable": 2000,
      "status": "active"
    }
  ]
}
```

---

### POST /api/grants/{grantId}/exercise-notice

Submits a Notice of Exercise for vested shares. Requires the employee's typed full legal name. Does not change the cap table.

**Auth:** Required. The grant must belong to the current user.

```bash
curl -s -X POST https://api.invisible.app/api/grants/11111111-0001-4000-8000-000000000001/exercise-notice \
  -H "Authorization: Bearer <jwt_token>" \
  -H "Content-Type: application/json" \
  -d '{"sharesToExercise": 1000, "affirmationName": "Jane Q. Employee"}'
```

**Request body:** `{ "sharesToExercise": number, "affirmationName": string }`

**Response (200):**

```json
{
  "ok": true,
  "noticeId": "aaaa1111-0001-4000-8000-000000000001",
  "status": "pending",
  "grantNumber": "511",
  "sharesToExercise": 1000,
  "exercisePrice": 2.80,
  "totalCost": 2800.00,
  "message": "Notice of Exercise submitted for 1,000 shares at $2.80 (total $2,800.00). Your administrator will record it once payment is received. A signed PDF is available on request."
}
```

Returns **400** with `ok: false` if the shares exceed what's exercisable or the affirmation name is missing.

---

### GET /api/exercise-notices

Lists the current user's own exercise notices.

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/exercise-notices \
  -H "Authorization: Bearer <jwt_token>"
```

**Response:** `{ "notices": [ { "noticeId", "grantId", "grantNumber", "sharesToExercise", "exercisePrice", "totalCost", "status", "affirmationName", "submittedAt", "processedAt" } ] }` — `status` is `pending`, `recorded`, `cancelled`, or `withdrawn`.

---

### POST /api/exercise-notices/{noticeId}/withdraw

Withdraws the current user's own pending notice.

**Auth:** Required. Only the notice's owner, only while pending.

```bash
curl -s -X POST https://api.invisible.app/api/exercise-notices/aaaa1111-0001-4000-8000-000000000001/withdraw \
  -H "Authorization: Bearer <jwt_token>"
```

**Response:** `{ "ok": true, "status": "withdrawn", "message": "Your Notice of Exercise has been withdrawn." }`

---

### GET /api/exercise-notices/{noticeId}/pdf

Returns the signed Notice of Exercise as a PDF. The owner can fetch their own; admins can fetch any in the org.

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/exercise-notices/aaaa1111-0001-4000-8000-000000000001/pdf \
  -H "Authorization: Bearer <jwt_token>" -o notice.pdf
```

**Response:** `application/pdf` bytes.

**Recording, cancelling, and admin listing** are available through the chat/MCP tools (`get_grant_data` (view `exercise_notices`), `record_exercise_notice`, `cancel_exercise_notice`) rather than dedicated REST endpoints — recording an exercise mutates the cap table and is an admin action.

### Correcting recorded exercises (chat/MCP tools)

A recorded exercise is never edited or deleted. A correction adds rows to the exercise record that offset it, linked to the exercise they correct, with the reason and who made it. Every total (exercised, exercisable, pool, post-termination deadlines) sums the record, so all of them follow. Owners and admins only; like the notice tools above, these are chat/MCP tools, not REST endpoints. Both take `confirm` (default `false`, a preview that writes nothing).

**`get_grant_data`, view `exercises`** (optional `grantId`, `stakeholderId`): the exercise record, oldest first per holder and grant.

```json
[
  {
    "exerciseId": "e1a2...",
    "grantId": "g323...",
    "grantNumber": "323",
    "stakeholderName": "Dana Reyes",
    "shares": 2749,
    "netShares": 2187,
    "exerciseDate": "2024-03-15T00:00:00",
    "exercisePrice": 2.50,
    "securityId": "EX-20240315-ab12",
    "kind": "exercise",
    "correctsExerciseId": null,
    "reason": null,
    "recordedAt": "2026-10-09T18:02:22Z"
  },
  {
    "exerciseId": "c7d8...",
    "grantId": "g316...",
    "grantNumber": "316",
    "shares": 562,
    "netShares": 562,
    "exerciseDate": "2024-03-15T00:00:00",
    "kind": "reallocation",
    "correctsExerciseId": "e1a2...",
    "reason": "Spreadsheet import split"
  }
]
```

`kind` is `exercise`, `reallocation` or `reversal`. `shares` is signed: a correction that takes shares off a grant is negative. `netShares`, on a row that added shares, is what remains of it on its grant after corrections. Form 3921 is filed per grant per exercise, from these rows grouped by grant and `exerciseDate`.

**`reallocate_exercise`**: moves shares of an exercise to another grant of the same holder at the same exercise price. The issued stock and the cap table don't change.

| Parameter | Type | Notes |
|---|---|---|
| `toGrantId` | string | Required. The grant the shares belong to |
| `shares` | integer | Required. Shares to move |
| `exerciseId` | string | The exercise to move from. Or `fromGrantId` when that grant has one exercise; with several, the response lists them in `candidates` |
| `fromGrantId` | string | See above |
| `reason` | string | Kept with the correction |
| `confirm` | boolean | `true` to record |

Refused (nothing written) when the target grant is another holder's, has a different exercise price, or lacks room (granted − cancelled − exercised), or when fewer shares of the exercise remain on its grant than requested.

**`reverse_exercise`**: takes back all or part of an exercise; those options become unexercised again.

| Parameter | Type | Notes |
|---|---|---|
| `exerciseId` | string | Required |
| `shares` | integer | Default: all that remain from this exercise on its grant |
| `cancelIssuedShares` | boolean | Default `true`. When the exercise issued the stock (a `type: exercise` transaction), records a `cancellation` of those shares dated to the exercise, with `securityId` `<original>/REVERSAL-<timestamp>`. A historical exercise's linked certificate is never cancelled |
| `reason` | string | Kept with the correction |
| `confirm` | boolean | `true` to record |

Both return:

```json
{
  "ok": true,
  "committed": true,
  "message": "Moved 562 shares of the 2024-03-15 exercise from grant 323 to grant 316. ...",
  "grants": [
    { "grantNumber": "323", "exercisedBefore": 2749, "exercisedAfter": 2187, "statusBefore": "partially_exercised", "statusAfter": "partially_exercised" },
    { "grantNumber": "316", "exercisedBefore": 0, "exercisedAfter": 562, "statusBefore": "active", "statusAfter": "partially_exercised" }
  ],
  "cancellationSecurityId": null
}
```

A grant whose exercises are all reversed goes back to `active` (or `cancelled` if nothing is left of it). A termination recorded before the correction keeps its cancelled shares; the vested shares the reversal frees are exercisable until the grant's deadline.

### Deleting grants in bulk (chat/MCP tool)

**`bulk_delete_option_grants`** (`grantIds`: up to 500 UUIDs; `confirm`): permanently deletes grants, for clearing a preliminary or mistaken import. Grants with an acceptance or exercise notice on record are skipped and named in `warnings`; ids not in the organization are returned in `notFound`. Exercise records of deleted grants go with them, but common stock their exercises issued stays in the ledger (also in `warnings`). Owners and admins only.

---

### Recording terminations (chat/MCP tool)

`record_termination` (owners and admins) records a departure for a holder (`stakeholderId`) or specific `grantIds`: it cancels the unvested remainder of each outstanding option grant and sets the post-termination exercise deadline. Parameters: `terminationDate`; `vestingEndDate` when vesting stops on a different day (garden leave, a notice period, negotiated extra vesting), which decides how many shares vested while the termination date still starts the exercise window and the ISO three-month clock; `exerciseWindowDays` or an explicit `exerciseDeadline`; `reason`; `notifyHolder` to email the holder their exercise deadlines once recorded; `confirm` (default `false`, a preview). A re-run (for example to extend the window) keeps a vesting end recorded earlier unless a new one is given. A re-run with a different vesting end recomputes from the grant as it stood before the termination: a later date gives back cancelled shares that vested (`sharesRestored`, reopening a grant the termination had closed out), an earlier one cancels more, and exercised shares are never cancelled. Each grant reports `exercisableUntilDeadline` (vested by the deadline, or by the vesting end if earlier, less exercised) and `exercisableNow` (of those, vested today); a warning is returned when vesting runs past the deadline. The holder email is sent only when asked for, counts toward the daily sending limit, and its outcome comes back as `holderNotice`.

### Form 3921 (chat/MCP tools)

Invisible prepares Form 3921 for every exercise of an incentive stock option; the company's filer files it with the IRS. Invisible never stores a full SSN.

`prepare_form_3921` (owners and admins): `taxYear`, `confirm` (default `false`, a preview), `correct`, `fmvOverrides` (array of `{ grantId, exerciseDate, fmvPerShare }`; once confirmed, an entered value is saved for that exercise and used ahead of any 409A, and `fmvPerShare` 0 clears it), and the company's details as transferor: `companyLegalName`, `companyEin` (XX-XXXXXXX), `companyAddressLine1`, `companyAddressLine2`, `companyCity`, `companyState`, `companyPostalCode`, `companyCountry`, `companyPhone` (saved on confirm, then reused). One statement per grant per exercise date with net ISO shares exercised (corrections net out): box 1 grant date, box 2 exercise date, box 3 exercise price, box 4 fair market value per share on the exercise date (the 409A in effect then, recorded with `record_409a_valuation` or a finalized Invisible 409A, or an `fmvOverride`), box 5 shares. The preview lists each statement's `missing` details (employee mailing address, `tinLast4`, box 4), `transferorMissing`, `excluded` exercises (more than 3 months after employment ended: taxed as nonqualified, no form), and `warnings` (a 409A more than a year old, a holder over the $100,000 ISO limit, a furnished statement whose exercise is gone). Confirming files each statement (Copy B, a substitute statement showing the truncated TIN) in the document repository as `form_3921`, linked to the employee and the grant, and files the IRS data file (CSV: every box, both parties, last four SSN digits) for the filer, who adds full SSNs from payroll and files Copy A (by February 28 on paper, March 31 electronically). Preparing again replaces prepared statements whose records or company details changed; a furnished one is replaced only with `correct=true`, as a CORRECTED statement. Each confirmed call prepares up to 25 statements and returns `remaining`; call again until it's 0. The IRS data file is written then, from the statements as issued, once no statement is missing details (so it covers every employee). Only one Form 3921 call (preparing or furnishing) runs at a time per company; a call made while another is running is told to wait.

`furnish_form_3921` (owners and admins): `taxYear`, `confirm`, `mailedStatementIds`. Employees with current consent to electronic delivery get an email that their statement is ready in Invisible (counts toward the daily sending limit) and it's recorded as furnished electronically. Each confirmed call emails up to 25 employees and returns `remaining`. Everyone else needs paper: once the emails are done, confirming returns `printDocumentId`, a PDF of up to 50 of their statements to print and mail to the addresses listed (each statement goes into one print PDF; call again for the next), and `mailedStatementIds` records them as furnished on paper. Statements are due to employees by January 31.

Employees find their statements with `get_cap_table_data` (view `documents`, category `form_3921`).

## Document Repository

Plan documents, consents, grant agreements, exercise notices, certificates, Forms 3921, 409A reports, financing documents and 83(b) elections, each linked to the stakeholders, grants, equity plans and security classes it concerns. Files are kept as filed, with their SHA-256 hash; documents are archived, never deleted. Every change is in the change history.

**Categories:** `plan_document`, `board_consent`, `stockholder_consent`, `grant_agreement`, `exercise_notice`, `stock_certificate`, `form_3921`, `valuation_report`, `financing`, `tax_election`, `other`.

**Who sees what:** owners, admins, members and full-access viewers see every document. Employees and restricted viewers see only their own `grant_agreement`, `exercise_notice`, `stock_certificate`, `form_3921` and `tax_election` documents, and `plan_document`s of plans they hold grants under, without links. 409A reviewers get `403`.

### GET /api/documents

Returns `{ "documents": [...], "truncated": false }`, newest document date first, at most 500; `truncated` is true when more match.

**Auth:** Required (see above).

**Query params:** `category`, `stakeholderId` (linked to the holder or one of their grants), `grantId`, `planId`, `securityClassId`, `search` (title, file name or description), `startDate` and `endDate` (document date), `includeArchived` (ignored for employees).

```bash
curl -s "https://api.invisible.app/api/documents?grantId=11111111-0001-4000-8000-000000000001" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "truncated": false,
  "documents": [
    {
      "id": "44444444-0001-4000-8000-000000000001",
      "title": "March 2024 board consent",
      "category": "board_consent",
      "description": "Approves the March grants",
      "documentDate": "2024-03-15T00:00:00",
      "fileName": "Consent 2024-03.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 182044,
      "sha256": "9f2c...",
      "uploadedBy": "Jesse Lipson",
      "uploadedAt": "2026-10-17T15:20:00Z",
      "archivedAt": null,
      "archiveReason": null,
      "links": [
        { "recordType": "option_grant", "recordId": "11111111-0001-4000-8000-000000000001", "label": "Grant 194 (Michael Cristinziano)" }
      ]
    }
  ]
}
```

### GET /api/documents/{id}/file

The file itself, with its content type and file name. `404` if it isn't in the organization or the caller can't see it.

### POST /api/documents

File a document. PDF, Word (`.docx`), Excel (`.xlsx`), PowerPoint (`.pptx`), CSV, text, PNG or JPEG, 4.2 MB at most; the file's first bytes must match its extension.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/documents \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fileName": "Consent 2024-03.pdf",
    "contentBase64": "JVBERi0xLjQK...",
    "category": "board_consent",
    "title": "March 2024 board consent",
    "documentDate": "2024-03-15",
    "links": { "grantIds": ["11111111-0001-4000-8000-000000000001"], "stakeholderIds": [], "planIds": [], "securityClassIds": [] }
  }'
```

**Response:** `{ "ok": true, "message": "Filed \"March 2024 board consent\".", "document": { ... } }`. `409` when the same file is already filed (the body includes that document), `400` for a file type, category or link that isn't allowed.

### PATCH /api/documents/{id}

Change a document's `title`, `category`, `description` or `documentDate` (`""` clears the last two), add links (`link`: `{ stakeholderIds, grantIds, planIds, securityClassIds }`), remove links (`unlink`: record ids of any kind), or archive it (`archived: true`, with `archiveReason`) or restore it (`archived: false`).

**Auth:** Required. `owner` or `admin` role.

**Chat and MCP:** `add_document` (in the chat, with the `attachmentId` of a PDF the user attached, filed exactly as attached; or with `contentBase64` and `fileName`), `update_document`, and `get_cap_table_data` with views `documents` and `document`.

---

## 4. Transactions

### GET /api/change-history

The cap table's change history, newest first. Every insert, update and delete on stakeholders, option grants, transactions, exercises, security classes, equity plans, vesting schedules, beneficial owners, investor groups, and repository documents and their links is recorded by the database, whatever made it. Recorded from October 2026; the history can't be edited or deleted.

**Auth:** Required. Owner, admin, member, or viewer with full cap table access. Employees, restricted viewers and 409A reviewers get `403`.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `stakeholderId` | uuid | Changes concerning this holder: their record, grants, transactions and exercises |
| `recordId` | uuid | Changes to one record |
| `changedBy` | uuid | Changes made by this user |
| `startDate` | date | From this date |
| `endDate` | date | Through this date |
| `limit` | integer | How many, newest first (default 100, at most 500) |

```bash
curl -s "https://api.invisible.app/api/change-history?recordId=11111111-0001-4000-8000-000000000001" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "changes": [
    {
      "changedAt": "2026-10-17T14:02:11Z",
      "action": "update",
      "record": "option grant",
      "recordId": "11111111-0001-4000-8000-000000000001",
      "grantNumber": "194",
      "stakeholderId": "22222222-0001-4000-8000-000000000001",
      "holder": "Michael Cristinziano",
      "changes": { "option_type": { "from": "iso", "to": "nqso" }, "notes": { "from": null, "to": "Advisor grant: NQSO" } },
      "changedBy": "Rob Moody",
      "changedByEmail": "rob@example.com",
      "changedByUserId": "33333333-0001-4000-8000-000000000001",
      "via": "mcp",
      "tool": "update_option_grant",
      "actionId": 30568
    }
  ],
  "truncated": false,
  "note": "Recorded from October 2026, when the change history was turned on; earlier changes aren't in it. Changes made in one action share an actionId."
}
```

`action` is `insert`, `update` or `delete`. An update's `changes` lists each changed field's `from` and `to`; an insert or delete lists the record's non-empty fields. Long text, such as plan or grant terms, shows as its length; read the record for the full value. `via` is `chat`, `mcp`, `api` or `system` (scheduled jobs). `tool` is the chat or MCP tool, or the API endpoint. Pass a `changedByUserId` as `changedBy` to see everything that person changed. Changes made by one action share `actionId`. `truncated` is true when more changes match than were returned.

MCP and chat: `get_cap_table_data` with view `change_history` takes the same filters.

---

### GET /api/transactions

Transaction history with optional filters.

**Auth:** Required.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `stakeholderId` | uuid | Filter by stakeholder |
| `securityClassId` | uuid | Filter by security class |
| `type` | string | One of: `issuance`, `grant`, `exercise`, `transfer`, `cancellation`, `repurchase`, `conversion` |
| `startDate` | string | Start date (`YYYY-MM-DD`) |
| `endDate` | string | End date (`YYYY-MM-DD`) |

```bash
curl -s "https://api.invisible.app/api/transactions?type=issuance&startDate=2024-01-01&endDate=2024-12-31" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "transactions": [
    {
      "id": "e5f6a7b8-0001-4000-8000-000000000001",
      "securityId": "CS-001",
      "type": "issuance",
      "shares": 4000000,
      "pricePerShare": 0.001,
      "date": "2024-01-15T00:00:00Z",
      "notes": "Founder shares"
    }
  ]
}
```

---

### POST /api/transactions

Record a transaction. Uses **preview/confirm pattern**.

**Auth:** Required. `owner`, `admin`, or `member` role.

**Enum values for `type`:** `issuance`, `grant`, `exercise`, `transfer`, `cancellation`, `repurchase`, `conversion`

#### Preview

```bash
curl -s -X POST https://api.invisible.app/api/transactions \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "securityId": "SA-001",
    "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000002",
    "securityClassId": "b2c3d4e5-0001-4000-8000-000000000002",
    "type": "issuance",
    "shares": 2000000,
    "pricePerShare": 1.50,
    "transactionDate": "2024-06-01",
    "notes": "Series A investment",
    "confirm": false
  }'
```

**Preview response:**

```json
{
  "preview": {
    "transactionDetail": {
      "securityId": "SA-001",
      "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000002",
      "securityClassId": "b2c3d4e5-0001-4000-8000-000000000002",
      "type": "issuance",
      "shares": 2000000,
      "pricePerShare": 1.50,
      "transactionDate": "2024-06-01",
      "notes": "Series A investment",
      "confirm": false
    },
    "validationWarnings": [],
    "impact": {
      "sharesBefore": 0,
      "sharesAfter": 2000000,
      "ownershipPctChange": 20.0
    }
  }
}
```

#### Commit

Same body with `"confirm": true`.

**Commit response:**

```json
{
  "transactionId": "e5f6a7b8-0001-4000-8000-000000000002",
  "preview": { "...": "same as above" }
}
```

---

## 5. Organization Management

### GET /api/me

Current user profile with all org memberships and linked stakeholders. Each membership lists the org's `products` (`cap` for Invisible Cap Table, `sign` for Invisible Sign).

**Auth:** Required. No `X-Organization-Id` needed.

```bash
curl -s https://api.invisible.app/api/me \
  -H "Authorization: Bearer <jwt_token>"
```

**Response:**

```json
{
  "userId": "44444444-0001-4000-8000-000000000001",
  "email": "sarah@acme.com",
  "name": "Sarah Chen",
  "organizations": [
    {
      "organizationId": "d4e5f6a7-0001-4000-8000-000000000001",
      "organizationName": "Acme Inc",
      "role": "owner",
      "canViewFullCapTable": true,
      "products": ["cap", "sign"]
    }
  ],
  "linkedStakeholders": [
    {
      "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000001",
      "organizationId": "d4e5f6a7-0001-4000-8000-000000000001",
      "organizationName": "Acme Inc",
      "name": "Sarah Chen",
      "type": "individual"
    }
  ]
}
```

---

### POST /api/organizations

Create a new organization. The creating user becomes `owner`. The name must be 1 to 100 characters with no web address or `<`/`>` (it appears in emails Invisible sends to other people); otherwise `400`.

**Auth:** Required (JWT). No `X-Organization-Id` needed.

```bash
curl -s -X POST https://api.invisible.app/api/organizations \
  -H "Authorization: Bearer <jwt_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Inc",
    "slug": "acme",
    "domain": "acme.com",
    "products": ["cap"]
  }'
```

`products` is optional: `["cap"]` (Invisible Cap Table, the default), `["sign"]` (Invisible Sign), or both.

**Response:**

```json
{ "organizationId": "d4e5f6a7-0001-4000-8000-000000000001" }
```

---

### GET /api/organizations/{id}/members

List all members of an organization.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s https://api.invisible.app/api/organizations/d4e5f6a7-0001-4000-8000-000000000001/members \
  -H "Authorization: Bearer <jwt_token>" \
  -H "X-Organization-Id: d4e5f6a7-0001-4000-8000-000000000001"
```

**Response:**

```json
{
  "members": [
    {
      "id": "55555555-0001-4000-8000-000000000001",
      "organizationId": "d4e5f6a7-0001-4000-8000-000000000001",
      "userId": "44444444-0001-4000-8000-000000000001",
      "role": "owner",
      "canViewFullCapTable": true,
      "createdAt": "2024-01-15T00:00:00Z"
    }
  ]
}
```

---

### POST /api/organizations/{id}/members

Invite a user to the organization. Everyone gets an invite code and link to accept, including people who already have an account; nobody joins an organization without accepting.

**Auth:** Required. `owner` or `admin` role. Only an `owner` can invite someone as `owner` (`403` otherwise). Invitations count toward the organization's daily sending limit (1,000 people a day; 25 a day in its first week); over it, `400` with an `error`.

**Enum values for `role`:** `owner`, `admin`, `member`, `viewer`, `employee`

```bash
curl -s -X POST https://api.invisible.app/api/organizations/d4e5f6a7-0001-4000-8000-000000000001/members \
  -H "Authorization: Bearer <jwt_token>" \
  -H "X-Organization-Id: d4e5f6a7-0001-4000-8000-000000000001" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "james@acme.com",
    "role": "member",
    "expiresInDays": 7
  }'
```

**Response (user exists):**

```json
{
  "added": true,
  "message": "User james@acme.com added to organization as member"
}
```

**Response (user must sign up):**

```json
{
  "added": false,
  "inviteCode": "abc123def456",
  "message": "Invite created for james@acme.com (user must sign up first)"
}
```

---

### PUT /api/organizations/{id}/members/{userId}

Update a member's role or cap table visibility.

**Auth:** Required. `owner` or `admin` role. Only an `owner` can make someone an owner or change an owner's role or visibility, and the organization's last owner can't be demoted (`403` with an `error` message).

```bash
curl -s -X PUT https://api.invisible.app/api/organizations/d4e5f6a7-0001-4000-8000-000000000001/members/44444444-0001-4000-8000-000000000002 \
  -H "Authorization: Bearer <jwt_token>" \
  -H "X-Organization-Id: d4e5f6a7-0001-4000-8000-000000000001" \
  -H "Content-Type: application/json" \
  -d '{
    "role": "admin",
    "canViewFullCapTable": true
  }'
```

**Response:**

```json
{ "updated": true }
```

---

### POST /api/org-invites/redeem

Redeem an organization invite code.

**Auth:** Required (JWT). No `X-Organization-Id` needed.

```bash
curl -s -X POST https://api.invisible.app/api/org-invites/redeem \
  -H "Authorization: Bearer <jwt_token>" \
  -H "Content-Type: application/json" \
  -d '{ "inviteCode": "abc123def456" }'
```

**Response:**

```json
{ "redeemed": true }
```

---

### POST /api/organizations/{id}/invite

Create a stakeholder invite that links a user account to a stakeholder record (e.g., so an employee can view their own holdings).

**Auth:** Required. `owner` or `admin` role.

**Optional fields:**

| Field | Type | Description |
|-------|------|-------------|
| `departments` | string[] | Department view access to grant on invite redemption |
| `canViewFullCapTable` | boolean | Whether to grant full cap table visibility on invite redemption |

These permissions are embedded in the invite and applied automatically when redeemed.

```bash
curl -s -X POST https://api.invisible.app/api/organizations/d4e5f6a7-0001-4000-8000-000000000001/invite \
  -H "Authorization: Bearer <jwt_token>" \
  -H "X-Organization-Id: d4e5f6a7-0001-4000-8000-000000000001" \
  -H "Content-Type: application/json" \
  -d '{
    "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
    "email": "james@acme.com",
    "expiresInDays": 7,
    "departments": ["Engineering", "Product"],
    "canViewFullCapTable": false
  }'
```

**Response:**

```json
{
  "inviteCode": "xyz789abc012",
  "expiresInDays": 7
}
```

---

### POST /api/invites/redeem

Redeem a stakeholder invite code (links the authenticated user to the stakeholder).

**Auth:** Required (JWT). No `X-Organization-Id` needed.

```bash
curl -s -X POST https://api.invisible.app/api/invites/redeem \
  -H "Authorization: Bearer <jwt_token>" \
  -H "Content-Type: application/json" \
  -d '{ "inviteCode": "xyz789abc012" }'
```

**Response:**

```json
{ "redeemed": true }
```

---

## Invitations

An invitation is addressed to an email address, not to an account, so it can be
created before the recipient has ever heard of Invisible. Accepting is always an
explicit act: inviting someone who already has an account creates an invitation
for them rather than adding them to the organization silently.

### GET /api/public/invites/{code}

Describe an invite code so the invitation page can be rendered before the
recipient has an account. Returns only what that page needs to show.

**Auth:** None. The invite code is the bearer secret.

```bash
curl -s https://api.invisible.app/api/public/invites/aB3dE5gH7jK9mN1pQ2r
```

**Response:**

```json
{
  "code": "aB3dE5gH7jK9mN1pQ2r",
  "kind": "organization",
  "status": "valid",
  "organizationName": "Real Magic, Inc.",
  "inviterName": "Jes Lipson",
  "invitedEmail": "jane@sequoia.com",
  "role": "viewer",
  "stakeholderName": null,
  "hasAccount": false
}
```

`kind` is `organization` or `stakeholder`. `status` is `valid`, `expired`,
`alreadyaccepted`, or `not_found`. `hasAccount` says whether that email already
has a login, which decides whether the page asks for a new password or an
existing one.

Note this returns **200 with `"status": "not_found"`** for an unknown code rather
than a 404. The CloudFront distribution in front of `invisible.app` rewrites any
403 or 404 from any origin into the SPA's `index.html`, so a 404 here would reach
the browser as HTML.

### POST /api/public/invites/{code}/confirm-signup

Confirm a Cognito account that was just created through an invitation link,
skipping the six-digit email verification code. Receiving the emailed invite
already proves the recipient controls that inbox.

The code must be valid, unredeemed, and addressed to exactly this email — without
that check the endpoint would confirm any account in the pool.

**Auth:** None.

```bash
curl -s -X POST https://api.invisible.app/api/public/invites/aB3dE5gH7jK9mN1pQ2r/confirm-signup \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@sequoia.com" }'
```

**Response:**

```json
{ "confirmed": true }
```

### GET /api/me/invites

List every unredeemed, unexpired invitation addressed to the authenticated
user's email, across both organization and stakeholder invites. Call this right
after signup to show someone what is waiting for them.

**Auth:** Required (JWT). No `X-Organization-Id` needed.

```bash
curl -s https://api.invisible.app/api/me/invites \
  -H "Authorization: Bearer <jwt_token>"
```

**Response:**

```json
{
  "invites": [
    {
      "code": "aB3dE5gH7jK9mN1pQ2r",
      "kind": "organization",
      "organizationName": "Real Magic, Inc.",
      "inviterName": "Jes Lipson",
      "role": "viewer",
      "stakeholderName": null,
      "expiresAt": "2026-09-26T14:03:11Z"
    }
  ]
}
```

### POST /api/invites/{code}/accept

Accept one invitation. Resolves organization and stakeholder invites through the
same route, so the caller does not need to know which kind it holds.

**Auth:** Required (JWT). No `X-Organization-Id` needed.

```bash
curl -s -X POST https://api.invisible.app/api/invites/aB3dE5gH7jK9mN1pQ2r/accept \
  -H "Authorization: Bearer <jwt_token>"
```

**Response:**

```json
{ "accepted": true, "kind": "organization" }
```

Returns 400 if the invitation is invalid, expired, or already accepted.

---

## Cognito Admin

Endpoints for the Invisible support team (allow-listed staff) to manage sign-in accounts across the platform. Any other caller, including an organization's owner or admin, gets `403`.

### GET /api/admin/cognito/users

List Cognito users with confirmation and verification status. Optionally filter by email.

**Auth:** Required. `owner` or `admin` role.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `email` | string | Filter by email address (partial match) |

```bash
curl -s "https://api.invisible.app/api/admin/cognito/users?email=grant@example.com" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "users": [
    {
      "username": "44444444-0001-4000-8000-000000000003",
      "email": "grant@example.com",
      "userStatus": "CONFIRMED",
      "emailVerified": true,
      "enabled": true,
      "createdAt": "2026-03-15T10:00:00Z"
    }
  ]
}
```

---

### POST /api/admin/cognito/confirm

Force-confirms an unverified Cognito user and sets their email as verified.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/admin/cognito/confirm \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "grant@example.com"
  }'
```

**Response:**

```json
{
  "confirmed": true,
  "message": "User grant@example.com confirmed and email marked as verified"
}
```

---

### POST /api/admin/cognito/resend-verification

Resends the verification code to an unconfirmed user.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/admin/cognito/resend-verification \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "grant@example.com"
  }'
```

**Response:**

```json
{
  "sent": true,
  "message": "Verification code resent to grant@example.com"
}
```

---

## 6. Bulk Operations

All bulk endpoints require `owner`, `admin`, or `member` role.

### POST /api/bulk/stakeholders

Create multiple stakeholders in one call.

**Auth:** Required. `owner`, `admin`, or `member` role.

**Enum values for `type`:** `individual`, `entity`, `trust`, `fund`, `spv`

```bash
curl -s -X POST https://api.invisible.app/api/bulk/stakeholders \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stakeholders": [
      {
        "name": "James Park",
        "type": "individual",
        "email": "james@acme.com",
        "jobTitle": "Senior Engineer",
        "department": "Engineering",
        "employeeId": "EMP-003"
      },
      {
        "name": "Acme Ventures LLC",
        "type": "entity",
        "email": "legal@acmeventures.com"
      }
    ]
  }'
```

**Response:**

```json
{
  "results": [
    { "name": "James Park", "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003", "error": null },
    { "name": "Acme Ventures LLC", "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000004", "error": null }
  ],
  "created": 2,
  "failed": 0
}
```

---

### POST /api/bulk/stakeholders/update

Update many stakeholders in one call, for example syncing employee IDs, names, titles or departments from an HR system. Each row names its stakeholder by `stakeholderId`, or by `lookupEmployeeId` (the employee ID already on the record), and changes only the fields it gives: `name`, `displayName`, `email`, `jobTitle`, `department`, `employeeId`, `managerId`, `clearManagerId`, `legalEntity`, the mailing address fields `addressLine1`, `addressLine2`, `city`, `region`, `postalCode`, `country`, and `tinLast4` (last four SSN digits only). Each row succeeds or fails on its own. At most 1,000 rows per call.

An employee ID can belong to only one stakeholder in an organization; a row that would give a second stakeholder the same ID fails. A `lookupEmployeeId` that matches more than one stakeholder (older data) fails too, so use `stakeholderId` for that row.

**Auth:** Required. `owner`, `admin`, or `member` role. MCP / chat tool: `bulk_update_stakeholders`.

```bash
curl -s -X POST https://api.invisible.app/api/bulk/stakeholders/update \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stakeholders": [
      { "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003", "employeeId": "EMP-003" },
      { "lookupEmployeeId": "EMP-007", "name": "Emily Coughlin", "displayName": "Emily" }
    ]
  }'
```

**Response:**

```json
{
  "results": [
    { "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003", "lookupEmployeeId": null, "updated": true, "error": null },
    { "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000007", "lookupEmployeeId": "EMP-007", "updated": true, "error": null }
  ],
  "updated": 2,
  "failed": 0
}
```

---

### POST /api/bulk/transactions

Record multiple transactions. Each transaction is committed individually (no preview step). Failures do not roll back successes.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X POST https://api.invisible.app/api/bulk/transactions \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "transactions": [
      {
        "securityId": "CS-002",
        "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
        "securityClassId": "b2c3d4e5-0001-4000-8000-000000000001",
        "type": "issuance",
        "shares": 100000,
        "pricePerShare": 0.50,
        "transactionDate": "2024-03-01",
        "notes": "Early exercise of options",
        "confirm": true
      },
      {
        "securityId": "SA-002",
        "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000004",
        "securityClassId": "b2c3d4e5-0001-4000-8000-000000000002",
        "type": "issuance",
        "shares": 500000,
        "pricePerShare": 1.50,
        "transactionDate": "2024-06-01",
        "notes": "Series A participation",
        "confirm": true
      }
    ]
  }'
```

**Response:**

```json
{
  "results": [
    { "securityId": "CS-002", "transactionId": "e5f6a7b8-0001-4000-8000-000000000003", "error": null },
    { "securityId": "SA-002", "transactionId": "e5f6a7b8-0001-4000-8000-000000000004", "error": null }
  ],
  "created": 2,
  "failed": 0
}
```

---

### POST /api/bulk/option-grants

Create multiple option grants. Each grant is committed individually. Failures do not roll back successes.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X POST https://api.invisible.app/api/bulk/option-grants \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "grants": [
      {
        "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
        "equityPlanId": "f6a7b8c9-0001-4000-8000-000000000001",
        "optionType": "iso",
        "sharesGranted": 50000,
        "exercisePrice": 0.50,
        "grantDate": "2024-03-01",
        "vestingStartDate": "2024-03-01",
        "expirationDate": "2034-03-01",
        "vestingScheduleId": "22222222-0001-4000-8000-000000000001",
        "status": "active",
        "category": "newHire",
        "confirm": true
      },
      {
        "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000005",
        "equityPlanId": "f6a7b8c9-0001-4000-8000-000000000001",
        "optionType": "nqso",
        "sharesGranted": 25000,
        "exercisePrice": 0.50,
        "grantDate": "2024-04-01",
        "vestingStartDate": "2024-04-01",
        "expirationDate": "2034-04-01",
        "status": "active",
        "category": "advisory",
        "confirm": true
      }
    ]
  }'
```

**Response:**

```json
{
  "results": [
    { "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003", "grantId": "11111111-0001-4000-8000-000000000001", "error": null },
    { "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000005", "grantId": "11111111-0001-4000-8000-000000000002", "error": null }
  ],
  "created": 2,
  "failed": 0
}
```

---

## 7. Additional Write Endpoints

### POST /api/stakeholders

Create a single stakeholder.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X POST https://api.invisible.app/api/stakeholders \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "James Park",
    "type": "individual",
    "email": "james@acme.com",
    "jobTitle": "Senior Engineer",
    "department": "Engineering",
    "employeeId": "EMP-003",
    "managerId": "a1b2c3d4-0001-4000-8000-000000000001",
    "legalEntity": "Acme Inc.",
    "addressLine1": "500 Market St",
    "city": "San Francisco",
    "region": "CA",
    "postalCode": "94105",
    "country": "US"
  }'
```

`country` takes a two-letter code (`US`, `CA`, `GB`...); "United States", "USA" and "Canada" are also accepted and stored as codes.

**Response:**

```json
{ "stakeholderId": "a1b2c3d4-0001-4000-8000-000000000003" }
```

---

### PUT /api/stakeholders/{id}

Update stakeholder fields. Only provided fields are modified.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X PUT https://api.invisible.app/api/stakeholders/a1b2c3d4-0001-4000-8000-000000000003 \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "department": "Engineering",
    "jobTitle": "Staff Engineer",
    "displayName": "Friendly Name",
    "email": "user@company.com"
  }'
```

**Request fields (all optional):**

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Full legal name |
| `email` | string | Email address |
| `department` | string | Department name |
| `jobTitle` | string | Job title |
| `displayName` | string | Friendly display name |
| `employeeId` | string | Employee ID |
| `managerId` | uuid | Manager stakeholder ID |
| `legalEntity` | string | Legal entity that employs them; `""` clears it |
| `addressLine1`, `addressLine2`, `city`, `region`, `postalCode` | string | Mailing address (Form 3921, mailed notices); `""` clears a field |
| `tinLast4` | string | Last four digits of their SSN or TIN, for the truncated TIN on Form 3921 (`""` clears it). Invisible never keeps a full SSN and refuses one. |
| `country` | string | Two-letter country code; `""` clears it |

**Response:**

```json
{ "updated": true }
```

Returns `404` if stakeholder not found.

---

### POST /api/stakeholders/merge

Merges two duplicate stakeholders. Moves all grants, transactions, and beneficial ownership from source to target. Deletes the source stakeholder.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/stakeholders/merge \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceStakeholderId": "a1b2c3d4-0001-4000-8000-000000000005",
    "targetStakeholderId": "a1b2c3d4-0001-4000-8000-000000000003"
  }'
```

**Response:**

```json
{
  "merged": true,
  "grantsMoved": 3,
  "transactionsMoved": 5,
  "beneficialOwnershipMoved": 1,
  "message": "Stakeholder merged successfully. All records moved to target."
}
```

---

### POST /api/security-classes

Create a new security class.

**Auth:** Required. `owner`, `admin`, or `member` role.

**Enum values for `type`:** `common`, `preferred`, `option`, `warrant`, `safe`, `convertibleNote`

```bash
curl -s -X POST https://api.invisible.app/api/security-classes \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Series B Preferred",
    "type": "preferred",
    "originalIssuePrice": 5.00,
    "liquidationPreference": 1.0,
    "conversionRatio": 1.0,
    "isParticipating": false,
    "seniorityRank": 2,
    "authorizedShares": 5000000
  }'
```

**Response:**

```json
{ "securityClassId": "b2c3d4e5-0001-4000-8000-000000000003" }
```

---

### PUT /api/security-classes

Update a security class.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X PUT https://api.invisible.app/api/security-classes \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "securityClassId": "b2c3d4e5-0001-4000-8000-000000000002",
    "authorizedShares": 4000000,
    "conversionRatio": 1.2,
    "notes": "Updated after anti-dilution adjustment"
  }'
```

**Response:**

```json
{ "updated": true }
```

---

### POST /api/equity-plans

Create a new equity incentive plan.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X POST https://api.invisible.app/api/equity-plans \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "2025 Equity Incentive Plan",
    "year": 2025,
    "authorizedShares": 1500000
  }'
```

**Response:**

```json
{ "equityPlanId": "f6a7b8c9-0001-4000-8000-000000000002" }
```

---

### PUT /api/equity-plans

Update an equity plan.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X PUT https://api.invisible.app/api/equity-plans \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "equityPlanId": "f6a7b8c9-0001-4000-8000-000000000001",
    "authorizedShares": 1200000
  }'
```

**Response:**

```json
{ "updated": true }
```

---

### POST /api/investor-groups

Create or update an investor group.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X POST https://api.invisible.app/api/investor-groups \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Series A Investors",
    "stakeholderIds": [
      "a1b2c3d4-0001-4000-8000-000000000002",
      "a1b2c3d4-0001-4000-8000-000000000004"
    ]
  }'
```

To update an existing group, include `groupId`:

```bash
curl -s -X POST https://api.invisible.app/api/investor-groups \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "groupId": "c3d4e5f6-0001-4000-8000-000000000001",
    "name": "Series A Investors",
    "stakeholderIds": [
      "a1b2c3d4-0001-4000-8000-000000000002",
      "a1b2c3d4-0001-4000-8000-000000000004",
      "a1b2c3d4-0001-4000-8000-000000000006"
    ]
  }'
```

**Response:**

```json
{ "groupId": "c3d4e5f6-0001-4000-8000-000000000001" }
```

---

## 8. MCP Integration

The Invisible Cap Table exposes an MCP (Model Context Protocol) server for AI assistant integrations.

**Endpoint:** `https://mcp.invisible.app/mcp`

**Transport:** Streamable HTTP (stateless)

**Auth:** OAuth 2.0 with PKCE. The authorization server metadata is at `https://api.invisible.app/.well-known/oauth-authorization-server`.

MCP clients (Claude Desktop, Cursor, etc.) can discover the server and authenticate automatically using the standard MCP OAuth flow. The server exposes the same cap table read/write operations as the REST API as MCP tools. Sign-in only completes for known MCP clients: the authorization code is sent only to Claude's and ChatGPT's registered callbacks or to a loopback address (`http://localhost`, `http://127.0.0.1`) for local clients, and PKCE is required. Other `redirect_uri` values get `invalid_redirect_uri`. Every token belongs to a person; machine (client-credentials) tokens are refused.

**Tokens.** `POST /token` returns Invisible's own opaque tokens, accepted only by this API: `access_token` (`ioat_...`, one hour, `expires_in: 3600`) and `refresh_token` (`iort_...`). Cognito's tokens are never handed to a connector. Each refresh token works once: `grant_type=refresh_token` returns a new access token and a new refresh token, so store the new one. Presenting a used refresh token again within two minutes (a retry whose answer was lost) is allowed once, and cancels the tokens the first exchange returned; any other reuse ends the whole connection, which then needs a new sign-in. A refresh token expires after 60 days unused, and a connection after a year. A connector's access token acts as its person, with their role in each organization, like a signed-in session (`X-Organization-Id` optional; see Choosing an organization). Connectors connected before October 2026 hold a Cognito refresh token; until 2026-11-12, their next refresh moves them to Invisible's tokens without a new sign-in (each Cognito token converts once). `/token` is rate-limited per address.

**Connected apps.** `get_account_data` (view `connected_apps`) lists your connections: `connectionId`, `name` (Claude, ChatGPT, App on your computer), `connectedAt`, `lastUsedAt`. `disconnect_app` (`connectionId`) ends one immediately. Only your own; they're per person, not per organization.

**How the tools are organized.** Reading goes through six tools, each with a required `view` parameter that picks the read: `get_cap_table_data`, `get_grant_data`, `get_valuation_data`, `get_signing_data`, `get_account_data`, and `get_support_data` (the Invisible support team only). Each tool's description lists its views and which parameters each view takes; a view the caller's role can't use is refused. Every change has its own descriptively named tool (`send_drafted_document`, `void_signature_request`, `record_transaction`), so a client's approval prompt says exactly what will happen. Tools carry MCP annotations: the six read tools are `readOnlyHint` (clients such as ChatGPT don't ask for approval to read), and tools that delete, void, cancel, revoke, merge or remove are `destructiveHint`. If your client cached the old tool list (individual `get_*` and `list_*` read tools, removed 2026-10-04), refresh or reconnect it.

### Bulk tool inputs

The bulk MCP tools (`bulk_create_stakeholders`, `bulk_update_stakeholders`, `bulk_record_transactions`, `bulk_create_option_grants`, `bulk_set_managers`) take their collection as a JSON string. Either shape is accepted:

```jsonc
// a bare array
[{ "stakeholderId": "...", "managerId": "..." }]

// or an object wrapping it, matching the REST body
{ "assignments": [{ "stakeholderId": "...", "managerId": "..." }] }
```

The wrapping property is `stakeholders`, `transactions`, `grants`, and `assignments` respectively. The REST endpoints themselves always take the wrapped object.

Large batches are applied one row at a time inside a single request, so keep a call to roughly 50 items to stay within the API Gateway timeout. Each row reports its own outcome in `results`, and one failed row does not abort the rest.

### Tool errors

A tool that fails returns an MCP error result whose content is the same JSON shape the REST API uses:

```json
{ "error": "Manager must be in the same organization." }
```

Validation and business-rule messages are passed through so a caller can correct the request. Unexpected server-side faults return a generic message and are logged server-side rather than exposed.

### Roles

Every tool is listed to every caller, and the caller's role in the active organization is checked on each call: a role may call the tools the chat offers it (see Role-Based Access). A viewer or employee calling a tool that changes the cap table, or a member calling an owner/admin tool such as `reverse_exercise` or `delete_option_grant`, gets an error result and nothing changes. Read tools check each `view` the same way, and the support team's tools check for Invisible staff.

---

## 9. 409A Valuations

### POST /api/409a/start

Start a new 409A valuation. Creates a draft report with initial inputs.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/409a/start \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "valuationDate": "2026-03-31",
    "companyDescription": "Series B SaaS company, $8M ARR, 45% YoY growth",
    "industry": "Enterprise Software / SaaS",
    "financials": {
      "revenue": 8000000,
      "revenueGrowthRate": 0.45,
      "ebitda": -1200000,
      "cashBalance": 12000000,
      "totalDebt": 0,
      "projectedRevenue": [12000000, 18000000, 26000000, 35000000, 42000000]
    },
    "dcfAssumptions": {
      "discountRate": 0.30,
      "terminalGrowthRate": 0.03,
      "projectionYears": 5
    },
    "comparableCompanies": [
      { "name": "Comparable A", "evRevenue": 12.5, "evEbitda": null },
      { "name": "Comparable B", "evRevenue": 9.8, "evEbitda": 45.0 },
      { "name": "Comparable C", "evRevenue": 15.2, "evEbitda": null }
    ],
    "methodologyWeights": {
      "dcf": 0.30,
      "marketComps": 0.40,
      "opmBacksolve": 0.30
    }
  }'
```

**Response:**

```json
{ "reportId": "99999999-0001-4000-8000-000000000001" }
```

---

### GET /api/409a/reports

List all 409A valuation reports for the organization.

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/409a/reports \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "reports": [
    {
      "id": "99999999-0001-4000-8000-000000000001",
      "valuationDate": "2026-03-31",
      "status": "draft",
      "fairMarketValue": null,
      "reviewerUserId": null,
      "createdAt": "2026-03-31T14:00:00Z",
      "finalizedAt": null
    }
  ]
}
```

---

### GET /api/409a/reports/{id}

Full report detail including inputs, calculated values, equity allocation, and review status.

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/409a/reports/99999999-0001-4000-8000-000000000001 \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "id": "99999999-0001-4000-8000-000000000001",
  "valuationDate": "2026-03-31",
  "status": "computed",
  "inputs": {
    "companyDescription": "Series B SaaS company, $8M ARR, 45% YoY growth",
    "industry": "Enterprise Software / SaaS",
    "financials": { "revenue": 8000000, "revenueGrowthRate": 0.45, "ebitda": -1200000 },
    "dcfAssumptions": { "discountRate": 0.30, "terminalGrowthRate": 0.03, "projectionYears": 5 },
    "comparableCompanies": [ "..." ],
    "methodologyWeights": { "dcf": 0.30, "marketComps": 0.40, "opmBacksolve": 0.30 }
  },
  "calculations": {
    "dcfValue": 85000000,
    "marketCompsValue": 96000000,
    "opmBacksolveValue": 91000000,
    "weightedEnterpriseValue": 91500000,
    "dlomDiscount": 0.25,
    "fairMarketValuePerShare": 1.85
  },
  "allocation": {
    "enterpriseValue": 91500000,
    "commonEquityValue": 18500000,
    "commonSharesOutstanding": 10000000,
    "preDlomPerShare": 1.85,
    "dlom": 0.25,
    "postDlomPerShare": 1.39
  },
  "reviewerUserId": null,
  "reviewNotes": null,
  "reviewApproved": null,
  "createdAt": "2026-03-31T14:00:00Z",
  "finalizedAt": null
}
```

---

### PUT /api/409a/reports/{id}/inputs

Update the inputs on a draft or computed report (financials, comparable companies, assumptions). Cannot update a finalized report.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X PUT https://api.invisible.app/api/409a/reports/99999999-0001-4000-8000-000000000001/inputs \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "financials": {
      "revenue": 8500000,
      "revenueGrowthRate": 0.48,
      "ebitda": -800000,
      "cashBalance": 11000000,
      "totalDebt": 0,
      "projectedRevenue": [13000000, 19500000, 28000000, 37000000, 44000000]
    },
    "comparableCompanies": [
      { "name": "Comparable A", "evRevenue": 13.0, "evEbitda": null },
      { "name": "Comparable B", "evRevenue": 10.2, "evEbitda": 42.0 },
      { "name": "Comparable C", "evRevenue": 14.8, "evEbitda": null },
      { "name": "Comparable D", "evRevenue": 11.5, "evEbitda": 50.0 }
    ]
  }'
```

**Response:**

```json
{ "updated": true }
```

Returns `400` if the report is finalized.

---

### POST /api/409a/reports/{id}/compute

Run the full valuation computation: DCF, Market Comps, OPM Backsolve, and DLOM calculation. Updates the report with calculated values.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/409a/reports/99999999-0001-4000-8000-000000000001/compute \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "computed": true,
  "fairMarketValuePerShare": 1.39,
  "enterpriseValue": 91500000,
  "methodResults": {
    "dcf": 85000000,
    "marketComps": 96000000,
    "opmBacksolve": 91000000
  },
  "dlomDiscount": 0.25
}
```

---

### POST /api/409a/reports/{id}/assign-reviewer

Assign a reviewer to a 409A report. The reviewer must be a member of the organization.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/409a/reports/99999999-0001-4000-8000-000000000001/assign-reviewer \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reviewerUserId": "44444444-0001-4000-8000-000000000002"
  }'
```

**Response:**

```json
{ "assigned": true }
```

---

### POST /api/409a/reports/{id}/review

Submit a review for a 409A report. Only the assigned reviewer can call this endpoint.

**Auth:** Required. Must be the assigned reviewer.

```bash
curl -s -X POST https://api.invisible.app/api/409a/reports/99999999-0001-4000-8000-000000000001/review \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "notes": "Methodology and assumptions look reasonable. Comparable set is appropriate for this stage and industry. DLOM within expected range.",
    "approve": true
  }'
```

**Response:**

```json
{ "reviewed": true, "approved": true }
```

Returns `403` if the caller is not the assigned reviewer.

---

### POST /api/409a/reports/{id}/finalize

Lock the report as final. Once finalized, no further edits are allowed. The fair market value is recorded for use in option pricing.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/409a/reports/99999999-0001-4000-8000-000000000001/finalize \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "finalized": true,
  "fairMarketValuePerShare": 1.39,
  "valuationDate": "2026-03-31"
}
```

Returns `400` if the report has not been computed yet.

---

## 10. SBC Expensing

### POST /api/sbc/valuations

Record a 409A valuation with Black-Scholes assumptions for SBC expense calculations.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/sbc/valuations \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "valuationDate": "2026-03-31",
    "fairMarketValue": 1.39,
    "expectedTermYears": 6.25,
    "riskFreeRate": 0.042,
    "volatility": 0.55,
    "dividendYield": 0.0,
    "notes": "Based on Q1 2026 409A valuation, volatility from public SaaS peer group"
  }'
```

**Response:**

```json
{ "sbcValuationId": "aaaaaaaa-0001-4000-8000-000000000001" }
```

---

### GET /api/sbc/valuations

List all SBC valuations (409A snapshots with Black-Scholes assumptions).

**Auth:** Required.

```bash
curl -s https://api.invisible.app/api/sbc/valuations \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "valuations": [
    {
      "id": "aaaaaaaa-0001-4000-8000-000000000001",
      "valuationDate": "2026-03-31",
      "fairMarketValue": 1.39,
      "expectedTermYears": 6.25,
      "riskFreeRate": 0.042,
      "volatility": 0.55,
      "dividendYield": 0.0,
      "notes": "Based on Q1 2026 409A valuation",
      "createdAt": "2026-03-31T15:00:00Z"
    }
  ]
}
```

---

### POST /api/sbc/compute

Compute Black-Scholes fair values and expense schedules for all active option grants, using the most recent SBC valuation assumptions.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/sbc/compute \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "computed": true,
  "grantsProcessed": 12,
  "totalFairValue": 485000.00,
  "totalUnamortizedExpense": 312000.00,
  "summary": {
    "iso": { "grants": 8, "fairValue": 350000.00 },
    "nqso": { "grants": 4, "fairValue": 135000.00 }
  }
}
```

---

### GET /api/sbc/expense-schedule

Monthly SBC expense schedule with optional filters. `groupBy=legal_entity` (or `department`) splits each month by that field, so each entity can book its share; stakeholders without one are grouped as `Unassigned`.

**Auth:** Required.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `startDate` | string | Start date (`YYYY-MM-DD`) |
| `endDate` | string | End date (`YYYY-MM-DD`) |
| `department` | string | Filter by department |
| `legalEntity` | string | Filter by legal entity |
| `groupBy` | string | `legal_entity` or `department`: one row per month per value |
| `grantId` | uuid | Filter by specific grant |

```bash
curl -s "https://api.invisible.app/api/sbc/expense-schedule?startDate=2026-01-01&endDate=2026-12-31&groupBy=legal_entity" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "schedule": [
    { "periodStart": "2026-01-01", "periodEnd": "2026-01-31", "group": "Acme Canada Ltd.", "expenseAmount": 4100.00, "grantsActive": 12 },
    { "periodStart": "2026-01-01", "periodEnd": "2026-01-31", "group": "Acme Inc.", "expenseAmount": 8400.00, "grantsActive": 31 },
    { "periodStart": "2026-02-01", "periodEnd": "2026-02-28", "group": "Acme Canada Ltd.", "expenseAmount": 4100.00, "grantsActive": 12 }
  ]
}
```

Without `groupBy`, `group` is null and there is one row per month.

---

### GET /api/sbc/disclosure

ASC 718 disclosure data for a fiscal year. Returns the information needed for stock-based compensation footnotes in financial statements.

**Auth:** Required.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `fiscalYearStart` | string | Fiscal year start date (`YYYY-MM-DD`) |
| `fiscalYearEnd` | string | Fiscal year end date (`YYYY-MM-DD`) |

```bash
curl -s "https://api.invisible.app/api/sbc/disclosure?fiscalYearStart=2025-01-01&fiscalYearEnd=2025-12-31" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "fiscalYear": { "start": "2025-01-01", "end": "2025-12-31" },
  "totalSbcExpense": 185000.00,
  "grantActivity": {
    "outstandingBeginning": { "shares": 450000, "weightedAvgExercisePrice": 0.65 },
    "granted": { "shares": 120000, "weightedAvgExercisePrice": 1.39 },
    "exercised": { "shares": 30000, "weightedAvgExercisePrice": 0.50 },
    "forfeited": { "shares": 15000, "weightedAvgExercisePrice": 0.75 },
    "outstandingEnd": { "shares": 525000, "weightedAvgExercisePrice": 0.82 },
    "exercisableEnd": { "shares": 210000, "weightedAvgExercisePrice": 0.60 }
  },
  "weightedAvgAssumptions": {
    "expectedTermYears": 6.25,
    "riskFreeRate": 0.042,
    "volatility": 0.55,
    "dividendYield": 0.0
  },
  "weightedAvgGrantDateFairValue": 0.92,
  "unamortizedExpense": 312000.00,
  "weightedAvgRemainingVestingYears": 2.8
}
```

---

## Visibility & Org Hierarchy

These endpoints manage manager relationships and department-based visibility. All require `owner` or `admin` role.

### PUT /api/stakeholders/{id}/manager

Set or clear a stakeholder's reporting manager.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X PUT https://api.invisible.app/api/stakeholders/a1b2c3d4-0001-4000-8000-000000000003/manager \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "managerId": "a1b2c3d4-0001-4000-8000-000000000001" }'
```

Pass `"managerId": null` to clear the manager. Returns `400` if the assignment would create a circular reference.

**Response:**

```json
{ "updated": true }
```

---

### POST /api/stakeholders/bulk-set-managers

Set manager relationships for multiple stakeholders at once.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/stakeholders/bulk-set-managers \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "assignments": [
      { "stakeholderId": "...", "managerId": "..." },
      { "stakeholderId": "...", "managerId": "..." }
    ]
  }'
```

**Response:**

```json
{
  "results": [
    { "stakeholderId": "...", "success": true, "error": null },
    { "stakeholderId": "...", "success": false, "error": "Would create circular reference" }
  ],
  "updated": 1,
  "failed": 1
}
```

---

### POST /api/department-access

Grant a user view access to all equity data in specified departments.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/department-access \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "c3d4e5f6-0001-4000-8000-000000000001",
    "departments": ["Sales", "Marketing"]
  }'
```

**Response:**

```json
{ "granted": 2 }
```

---

### POST /api/department-access/revoke

Revoke department-based view access.

**Auth:** Required. `owner` or `admin` role.

```bash
curl -s -X POST https://api.invisible.app/api/department-access/revoke \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "c3d4e5f6-0001-4000-8000-000000000001",
    "departments": ["Marketing"]
  }'
```

**Response:**

```json
{ "revoked": 1 }
```

---

### GET /api/department-access

List all department access grants.

**Auth:** Required. `owner` or `admin` role.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `userId` | uuid | Filter to a specific user |

```bash
curl -s "https://api.invisible.app/api/department-access?userId=c3d4e5f6-0001-4000-8000-000000000001" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "departmentAccess": [
    {
      "id": "...",
      "userId": "c3d4e5f6-0001-4000-8000-000000000001",
      "department": "Sales",
      "grantedBy": "...",
      "createdAt": "2026-04-06T00:00:00Z"
    }
  ]
}
```

---

## Beneficial Ownership

Track beneficial ownership relationships (who owns what percentage of an entity stakeholder).

### POST /api/beneficial-owners

Create or update a beneficial ownership record.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X POST https://api.invisible.app/api/beneficial-owners \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entityStakeholderId": "a1b2c3d4-0001-4000-8000-000000000004",
    "ownerStakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
    "ownershipPct": 3.0055,
    "notes": "Direct ownership through LLC"
  }'
```

**Response:**

```json
{ "id": "bbbbbbbb-0001-4000-8000-000000000001" }
```

---

### GET /api/beneficial-owners

List beneficial ownership records. Filter by entity or owner.

**Auth:** Required. Any authenticated user.

**Query params:**

| Param | Type | Description |
|-------|------|-------------|
| `entityStakeholderId` | uuid | Filter by entity stakeholder |
| `ownerStakeholderId` | uuid | Filter by owner stakeholder |

```bash
curl -s "https://api.invisible.app/api/beneficial-owners?entityStakeholderId=a1b2c3d4-0001-4000-8000-000000000004" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{
  "beneficialOwners": [
    {
      "id": "bbbbbbbb-0001-4000-8000-000000000001",
      "entityStakeholderId": "a1b2c3d4-0001-4000-8000-000000000004",
      "entityName": "Real Magic LLC",
      "ownerStakeholderId": "a1b2c3d4-0001-4000-8000-000000000003",
      "ownerName": "Grant Kitching",
      "ownershipPct": 3.0055,
      "sharesEquivalent": 180330
    }
  ]
}
```

---

### DELETE /api/beneficial-owners/{entityId}/{ownerId}

Remove a beneficial ownership record.

**Auth:** Required. `owner`, `admin`, or `member` role.

```bash
curl -s -X DELETE https://api.invisible.app/api/beneficial-owners/a1b2c3d4-0001-4000-8000-000000000004/a1b2c3d4-0001-4000-8000-000000000003 \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
{ "removed": true }
```

---

## Invisible Sign (E-Signature)

Send documents for e-signature, track them, and download the sealed result. Kinds: `grant_agreement`, `stock_certificate`, `drafted_document` (written by the assistant and sent from a draft, see Drafts below), and `uploaded` (a PDF you upload, with fields placed by Invisible, see Uploaded documents below). For `grant_agreement`, Invisible generates the stock option grant agreement from the grant and places every field itself. If the org has an option signatory, they sign first (`routingOrder` 1), then the optionee. Signers sign at `https://invisible.app/sign/{token}` from their email; they need no account.

Each recipient has an `action`: `sign`, or `receive_copy` for someone who gets the sealed document at completion without signing (the holder of a stock certificate). Status values are snake_case strings. Request: `draft`, `sent`, `in_progress`, `completed`, `declined`, `voided`, `expired`. Recipient: `pending` (not emailed yet, waiting for an earlier signer), `sent`, `viewed`, `consented`, `signed`, `declined`.

All `/api/sign/*` endpoints require authentication and the `owner` or `admin` role.

### POST /api/sign/requests

Generate a document and send it for signature.

```bash
curl -s -X POST https://api.invisible.app/api/sign/requests \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "grant_agreement",
    "grantId": "a1b2c3d4-0002-4000-8000-000000000042",
    "message": "Welcome aboard!"
  }'
```

**Response:**

```json
{
  "message": "Sent \"Stock Option Grant Agreement for Priya Shah (#42)\" to Jesse Lipson. Priya Shah will get it after that. The link expires 2026-10-31.",
  "request": {
    "id": "e48f3833-167d-49a8-ae58-b9e4132c1c7e",
    "title": "Stock Option Grant Agreement for Priya Shah (#42)",
    "status": "sent",
    "orderMode": "sequential",
    "sourceKind": "grant_agreement",
    "sourceRef": "a1b2c3d4-0002-4000-8000-000000000042",
    "createdAt": "2026-10-01T14:51:41Z",
    "sentAt": "2026-10-01T14:51:41Z",
    "expiresAt": "2026-10-31T14:51:41Z",
    "hasSignedDocument": false,
    "recipients": [
      { "name": "Jesse Lipson", "email": "jesse@example.com", "roleLabel": "Company", "routingOrder": 1, "status": "sent", "signedAt": null },
      { "name": "Priya Shah", "email": "priya@example.com", "roleLabel": "Optionee", "routingOrder": 2, "status": "pending", "signedAt": null }
    ],
    "evidence": [
      { "event": "created", "recipient": null, "occurredAt": "2026-10-01T14:51:41Z", "ipAddress": null },
      { "event": "sent", "recipient": "Jesse Lipson", "occurredAt": "2026-10-01T14:51:41Z", "ipAddress": null }
    ]
  }
}
```

**Errors (400):** grant not found; grant already accepted or declined; stakeholder has no email on file; an open request already exists for this grant (resend or void it instead).

**Stock certificates:** send `{ "kind": "stock_certificate", "certificateNumber": "PA-3" }` or `{ "kind": "stock_certificate", "stakeholderId": "..." }` for all of a stakeholder's common and preferred holdings. The two certificate signatories (`/api/sign/settings/certificates`) sign in order; the holder is a copy recipient (`action: "receive_copy"`) emailed the sealed certificate at completion. A holding that already has a completed certificate is re-sent to the holder. `message` lists one line per certificate.

---

### GET /api/sign/requests

List requests, newest first. Optional `status` and `kind` query filters.

```bash
curl -s "https://api.invisible.app/api/sign/requests?status=in_progress" \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

**Response:**

```json
[
  {
    "id": "e48f3833-167d-49a8-ae58-b9e4132c1c7e",
    "title": "Stock Option Grant Agreement for Priya Shah (#42)",
    "status": "in_progress",
    "sourceKind": "grant_agreement",
    "sourceRef": "a1b2c3d4-0002-4000-8000-000000000042",
    "recipientCount": 2,
    "signedCount": 1,
    "createdAt": "2026-10-01T14:51:41Z",
    "sentAt": "2026-10-01T14:51:41Z",
    "completedAt": null,
    "expiresAt": "2026-10-31T14:51:41Z"
  }
]
```

---

### GET /api/sign/requests/{id}

One request with recipients and the evidence trail (same shape as `request` above).

---

### POST /api/sign/requests/{id}/resend

Email everyone who was sent the request but has not finished, with fresh links, and extend the expiry. Their earlier links stop working.

```bash
curl -s -X POST https://api.invisible.app/api/sign/requests/e48f3833-167d-49a8-ae58-b9e4132c1c7e/resend \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

---

### POST /api/sign/requests/{id}/void

Void an open request so its links stop working.

```bash
curl -s -X POST https://api.invisible.app/api/sign/requests/e48f3833-167d-49a8-ae58-b9e4132c1c7e/void \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Wrong share count; reissuing" }'
```

---

### GET /api/sign/requests/{id}/document

The sealed, signed PDF (with its certificate of completion). `404` until everyone has signed.

```bash
curl -s -o signed.pdf https://api.invisible.app/api/sign/requests/e48f3833-167d-49a8-ae58-b9e4132c1c7e/document \
  -H "Authorization: Bearer icap_YOUR_KEY"
```

---

### GET /api/sign/settings/reminders

```json
{
  "settings": {
    "remindersEnabled": true, "reminderIntervalDays": 3, "reminderMax": 3,
    "grantAcceptanceReminders": false, "grantAcceptanceIntervalDays": 7, "grantAcceptanceMax": 3,
    "exerciseDeadlineDigest": true
  },
  "summary": "Signers are reminded every 3 days, up to 3 times, until they sign. Employees with grants waiting to be accepted are not reminded automatically. Owners and admins get a Monday email listing option exercise deadlines in the next 30 days and grants that lapsed in the last week (only when there are any). Reminder changes apply to documents sent from now on."
}
```

Signer reminders go to whoever's turn it is on open, unexpired requests, as a fresh link (earlier links stop working); they never extend the expiry. Grant-acceptance reminders cover the cap table's typed-name acceptance flow: linked employees get a reminder to accept, others the account-setup invite. Reminders run hourly. `exerciseDeadlineDigest` (on by default) sends the org's owners and admins a Monday email of option exercise deadlines in the next 30 days and grants that lapsed in the last week; no email goes out in a week with none.

### PUT /api/sign/settings/reminders

Only the fields sent change. Intervals 1 to 30 days; limits 1 to 10. Applies to requests sent from then on.

```bash
curl -s -X PUT https://api.invisible.app/api/sign/settings/reminders \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "intervalDays": 2, "maxReminders": 5, "grantAcceptanceReminders": true }'
```

### POST /api/sign/requests/{id}/reminders

Turns reminders on or off for one open request, or changes its schedule: `{ "enabled": false }` or `{ "intervalDays": 7, "maxReminders": 2 }`. Returns `{ "message", "request" }`. Request views include `reminders: { "enabled", "intervalDays", "max" }` and, per recipient, `remindersSent`; reminders appear in `evidence` as `reminded`.

### GET /api/sign/settings/option-signatory

```json
{ "signatory": { "userId": "u1111111-0000-4000-8000-000000000001", "name": "Jesse Lipson", "email": "jesse@example.com", "title": "Chief Executive Officer" } }
```

`signatory` is `null` when none is set.

### PUT /api/sign/settings/option-signatory

Set who countersigns grant agreements for the company. The user must be an owner or admin of the org. Send `"userId": null` to clear it.

```bash
curl -s -X PUT https://api.invisible.app/api/sign/settings/option-signatory \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "userId": "u1111111-0000-4000-8000-000000000001", "title": "Chief Executive Officer" }'
```

---

### GET /api/sign/settings/certificates

```json
{
  "signatory1": { "userId": "u1111111-0000-4000-8000-000000000001", "name": "Jesse Lipson", "email": "jesse@example.com", "title": "Chief Executive Officer" },
  "signatory2": { "userId": "u2222222-0000-4000-8000-000000000002", "name": "Robert Mills", "email": "robert@example.com", "title": "Secretary" },
  "stateOfIncorporation": "Delaware",
  "parValuePerShare": 0.00001,
  "legend": null,
  "ready": true
}
```

### PUT /api/sign/settings/certificates

Only the fields sent change. The two signatories must be different owners or admins of the org. `legend` replaces the standard Securities Act legend; `"clearLegend": true` goes back to it.

```bash
curl -s -X PUT https://api.invisible.app/api/sign/settings/certificates \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "signatory1UserId": "u1111111-0000-4000-8000-000000000001", "signatory1Title": "Chief Executive Officer",
    "signatory2UserId": "u2222222-0000-4000-8000-000000000002", "signatory2Title": "Secretary",
    "stateOfIncorporation": "Delaware", "parValuePerShare": 0.00001
  }'
```

---

### Drafts: POST /api/sign/drafts

Renders a document from structured content and stores it as a draft with every signature, name, title and date field placed (one signature block per party). Nothing is sent. Pass `?draftId=` to revise an unsent draft in place. A party with `isEntity: true` gets By, Name and Title lines, prefilled from `signerName` and `signerTitle` when given; a person gets Signature and Name. Every party gets a Date line, filled in at signing.

```bash
curl -s -X POST https://api.invisible.app/api/sign/drafts \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Mutual Non-Disclosure Agreement",
    "kind": "mutual_nda",
    "sections": [
      { "heading": "1. Parties", "paragraphs": ["This Agreement is between Real Magic Inc. and Northwind Analytics, Inc."] },
      { "heading": "2. Confidential Information", "paragraphs": ["Each party will keep the other party'"'"'s Confidential Information confidential for two years."] }
    ],
    "parties": [
      { "role": "Northwind", "name": "Northwind Analytics, Inc.", "isEntity": true },
      { "role": "Real Magic", "name": "Real Magic Inc.", "isEntity": true, "signerName": "Jesse Lipson", "signerTitle": "Chief Executive Officer" }
    ]
  }'
```

Response:

```json
{
  "message": "Drafted \"Mutual Non-Disclosure Agreement\" (2 pages), with signature blocks for Northwind, Real Magic. Nothing has been sent.",
  "draft": { "id": "d1111111-0000-4000-8000-000000000001", "title": "Mutual Non-Disclosure Agreement", "kind": "mutual_nda", "pageCount": 2, "roles": ["Northwind", "Real Magic"], "updatedAt": "2026-10-04T15:02:11Z", "sentRequestId": null }
}
```

Limits: 1 to 6 parties, each with its own role; 1 to 60 sections; paragraphs under 6,000 characters.

### GET /api/sign/drafts

Drafts, newest first. Unsent only by default; `?includeSent=true` for all. Returns `{ "drafts": [ <draft>, ... ] }`.

### GET /api/sign/drafts/{id}

`{ "message", "draft", "content", "upload", "fields" }`. For a drafted document, `content` is the structured content last rendered (send it back to POST /api/sign/drafts with `?draftId=` to revise). For an uploaded document, `upload` has the file name, `normalization` (`as-is` or `copied`), the signer roles and whether fields are placed, and `fields` lists them numbered as in the preview. `?includeSpots=true` adds `upload.spots`: every place a field can be added (`id`, `page`, `kind`, `rect`, `context`).

### GET /api/sign/drafts/{id}/document

The draft PDF exactly as it will be sent; for an uploaded document with fields placed, the preview with every field numbered and colored by signer.

### Uploaded documents: POST /api/sign/uploads

The PDF is the request body (`Content-Type: application/pdf`), up to 4.2 MB and 50 pages; `?fileName=` names it. Creates a draft of kind `uploaded`. A PDF with edit restrictions (owner password) is copied into a plain PDF so it can be signed (`normalization: "copied"`); one that needs a password to open is refused.

```bash
curl -s -X POST "https://api.invisible.app/api/sign/uploads?fileName=engagement-letter.pdf" \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/pdf" \
  --data-binary @engagement-letter.pdf
```

Response: `{ "message": "Uploaded \"engagement-letter.pdf\" (2 pages).", "draft": { "id": "...", "kind": "uploaded", "roles": [], ... } }`

### POST /api/sign/drafts/{id}/prepare

Places the fields on an uploaded document. Invisible extracts every place a mark could go (drawn lines, typed underscores, table cells, boxes, form widgets, blank space after a label; scanned pages from the image) and a model chooses which are fields, of what type, and for which signer. The whole document is completed for the signers, forms included, skipping what is already filled in and options already chosen. Body: `{ "signers": ["Client", "Accountant"], "note": "optional guidance" }`; omit `signers` to read them from the document. Running it again starts over.

Response: `{ "message", "draft", "fields": [ { "number": 1, "page": 2, "type": "signature", "role": "Client", "label": "under: Client signature" }, ... ] }`. Fetch the numbered preview from `GET /api/sign/drafts/{id}/document`.

### POST /api/sign/drafts/{id}/fields

Corrects fields by the numbers in the preview; they are renumbered after.

```json
{
  "remove": [4],
  "change": [ { "field": 2, "role": "Client", "type": "date_signed" } ],
  "add": [ { "spot": "p2c14", "type": "signature", "role": "Client" } ]
}
```

`type` is one of `signature`, `initials`, `date_signed`, `text`, `checkbox`. Spot ids come from `GET /api/sign/drafts/{id}?includeSpots=true`. A new role name becomes a signer; a signer left with no fields is dropped. Send with `POST /api/sign/drafts/{id}/send` as for drafted documents; every signer needs at least one signature or initials field.

### POST /api/sign/drafts/{id}/send

Sends the draft as previewed. Name one recipient per party, by the party's `role`: a `name` and `email`, or `"me": true` for the signed-in user. `order` is `parallel` (default) or `sequential` (in the parties' order). A draft is sent once; afterwards `sentRequestId` points at the signature request (kind `drafted_document`) and the draft can't be revised.

```bash
curl -s -X POST https://api.invisible.app/api/sign/drafts/d1111111-0000-4000-8000-000000000001/send \
  -H "Authorization: Bearer icap_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": [
      { "role": "Northwind", "name": "Dana Lee", "email": "dana@northwind.com" },
      { "role": "Real Magic", "me": true }
    ],
    "order": "sequential",
    "message": "Here is the NDA we discussed."
  }'
```

Response: `{ "message": "Sent \"Mutual Non-Disclosure Agreement\" to Dana Lee. Jesse Lipson will get it after that. ...", "request": <signature request> }`. Errors (`400`): a role that isn't a party, a party with no recipient, a missing or invalid email, or a draft already sent.

---

### POST /api/sign/sweep

Retry completion (stamp, certificate, seal, store, notify) for any request, in any organization, where everyone signed but completion did not finish. Returns `{ "completed": <count> }`. The Invisible support team only (`403` otherwise); the hourly scheduler runs the same sweep on its own.

---

### Signer endpoints: /api/public/sign/{token}

Used by the signer page. No authentication: the token from the signing email is the credential. Rate limited per client address (429 when exceeded). Responses carry `Referrer-Policy: no-referrer` and `Cache-Control: no-store`.

| Method | Path | Body | Purpose |
|---|---|---|---|
| GET | `/api/public/sign/{token}` | | Signer view: title, sender, organization, this recipient's fields (points, top-left origin), consent text and version, `consented`, `consentOnFileSince` (when the signer's standing consent for this organization was given, or null), other recipients' first names and statuses, and `blocked` (why they can't act, or null). Opening the view with standing consent on file moves the recipient straight to `consented` and logs a `consent_on_file` evidence event |
| GET | `/api/public/sign/{token}/document` | | The PDF: the source document while signing is open, the sealed copy once complete |
| POST | `/api/public/sign/{token}/consent` | `{ "version": "invisible-esign-consent-v2" }` | Record ESIGN consent. v2 is standing consent: it covers every document this organization sends the signer (matched by email) until withdrawn |
| POST | `/api/public/sign/{token}/withdraw-consent` | | Withdraw the signer's standing consent for this organization. If they hadn't signed this request yet, it returns to the consent step |
| POST | `/api/public/sign/{token}/sign` | `{ "adoptedName", "signatureKind": "drawn"\|"typed", "signaturePngBase64", "values": [{ "fieldId", "value" }] }` | Sign. Validates required fields; the date is filled in by the server |
| POST | `/api/public/sign/{token}/decline` | `{ "reason" }` | Decline; closes the request and notifies the sender |

Action responses: `{ "ok": true, "message": "...", "completed": false }`, or `400` with `ok: false` and a message to show the signer.

---

## Error Responses

All errors follow this format:

```json
{ "error": "Description of what went wrong" }
```

| Status | Meaning |
|--------|---------|
| 400 | Bad request (missing fields, invalid data) |
| 401 | Missing/invalid auth token |
| 403 | Insufficient permissions for this action |
| 404 | Resource not found |
