{
  "openapi": "3.0.3",
  "info": {
    "title": "PitchStation Share API",
    "version": "2026-08",
    "description": "Publish a document and get a secure, tracked /s/<slug> link with analytics, access control, expiry, password, and revoke. Auth: `Authorization: Bearer pst_...` (Personal Access Token, mint at /tokens.html). RULES FOR ASSISTANTS: (1) To UPDATE a published document use POST /api/share/{slug}/replace, never a second publish — republishing mints a new URL and splits analytics. (2) aclUsernames applies to access:'account' only. (3) recipients[] applies to access:'recipient' only — each gets a personal magic link, emailed automatically. (4) Never echo the share password back into chat unless asked. (5) Safe default is access:'account'; use access:'link' + expires_in + password for external sends. Human quickstart: https://www.pitchstation.ai/ai-skill-setup.html Compatibility: additive evolution only.",
    "contact": {
      "email": "hello@pitchstation.ai"
    }
  },
  "servers": [
    {
      "url": "https://www.pitchstation.ai"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal Access Token (pst_...)"
      }
    },
    "parameters": {
      "slug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "The share id from the /s/<slug> URL."
      }
    },
    "schemas": {
      "BundleFile": {
        "type": "object",
        "required": [
          "title"
        ],
        "properties": {
          "title": {
            "type": "string"
          },
          "htmlContent": {
            "type": "string",
            "description": "Raw HTML string."
          },
          "pdfBase64": {
            "type": "string",
            "description": "Base64 PDF bytes (must start with %PDF-)."
          },
          "docxBase64": {
            "type": "string"
          },
          "pptxBase64": {
            "type": "string"
          },
          "markdown": {
            "type": "string"
          },
          "text": {
            "type": "string"
          },
          "imageBase64": {
            "type": "string"
          },
          "primary": {
            "type": "boolean",
            "description": "Exactly one file may be primary; defaults to the first."
          }
        }
      },
      "PublishRequest": {
        "type": "object",
        "description": "Exactly one content source: htmlContent | htmlBase64 | pdfBase64 | docxBase64 | pptxBase64 | files[] (multi-file bundle: one URL, sidebar viewer).",
        "properties": {
          "title": {
            "type": "string"
          },
          "htmlContent": {
            "type": "string",
            "description": "Raw HTML document. Most common."
          },
          "htmlBase64": {
            "type": "string"
          },
          "pdfBase64": {
            "type": "string"
          },
          "docxBase64": {
            "type": "string"
          },
          "pptxBase64": {
            "type": "string"
          },
          "files": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BundleFile"
            },
            "description": "Multi-file bundle. One URL / password / expiry / analytics scope for all files."
          },
          "access": {
            "type": "string",
            "enum": [
              "account",
              "link",
              "recipient"
            ],
            "default": "account",
            "description": "account = signed-in users (safe default, optionally narrowed by aclUsernames/teams); link = anyone with the URL (pair with expires_in + password); recipient = per-email magic links."
          },
          "aclUsernames": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "account mode only: restrict to these usernames."
          },
          "teams": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "account mode: team handles or '@owner/handle' refs whose members may view. An ambiguous bare handle is refused with TEAM_AMBIGUOUS + candidates — never guess; ask the user."
          },
          "teamIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Exact team ids — never ambiguous, the safest form for an agent."
          },
          "notifyTeams": {
            "type": "boolean",
            "description": "Publishing to a team emails every member automatically; false publishes quietly. Default true."
          },
          "recipients": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "email"
            },
            "description": "recipient mode: each address gets its own magic link, emailed automatically."
          },
          "expires_in": {
            "type": "string",
            "description": "'24h', '7d', '10d', '30d', 'never', or an ISO timestamp."
          },
          "password": {
            "description": "true to auto-generate a memorable password (returned once in the response), or a custom string.",
            "oneOf": [
              {
                "type": "boolean"
              },
              {
                "type": "string"
              }
            ]
          },
          "max_views": {
            "type": "integer",
            "description": "Auto-expire after N opens."
          },
          "watermark": {
            "type": "boolean",
            "description": "Stamp viewer identity + timestamp onto HTML pages."
          },
          "downloads": {
            "type": "boolean",
            "description": "Allow downloading the file. Default false."
          },
          "require_name": {
            "type": "boolean",
            "description": "link mode: viewers must give a name before viewing."
          },
          "allow_forward": {
            "type": "boolean",
            "description": "Viewers may mint their own tracked links; opens roll up to the origin."
          },
          "comments": {
            "type": "boolean",
            "description": "Enable inline comment threads on the document."
          }
        }
      },
      "PublishResponse": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "slug": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "description": "The share link to give out. link-mode URLs carry the ?k= access key — always share the FULL url."
          },
          "password": {
            "type": "string",
            "description": "Present once when a password was set/generated. Relay it to the user beside the URL."
          },
          "recipients": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "email": {
                  "type": "string"
                },
                "magic_link": {
                  "type": "string"
                }
              }
            },
            "description": "recipient mode: per-email personal links (also emailed automatically)."
          },
          "notified": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Addresses that were emailed."
          }
        }
      }
    }
  },
  "paths": {
    "/api/share": {
      "post": {
        "operationId": "publishShare",
        "summary": "Publish a document, get a tracked /s/<slug> link",
        "description": "Also accepts Content-Type: text/html with the raw document as the body and controls as query parameters (title, access, expires, password, max_views, watermark, downloads, recipients) — no JSON escaping needed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PublishRequest"
              }
            },
            "text/html": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublishResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (e.g. aclUsernames with access:'link')"
          },
          "413": {
            "description": "Payload over the size cap — strip embedded base64 images or publish as PDF"
          }
        }
      },
      "get": {
        "operationId": "listShares",
        "summary": "List shares owned by the caller",
        "responses": {
          "200": {
            "description": "OK — { shares: [{ slug, title, access, revoked, views, expires_at, url }] }"
          }
        }
      }
    },
    "/api/share/{slug}": {
      "get": {
        "operationId": "getShare",
        "summary": "Share detail: settings, recipients, teams, signature status",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Not found or not yours"
          }
        }
      },
      "patch": {
        "operationId": "editShare",
        "summary": "Change controls in place (same URL, analytics intact)",
        "description": "Settings only — password (string or null to clear), expires_in, max_views, watermark, downloads, require_name, comments. To change WHO can view, use PATCH /access. To change content, use /replace.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK — response lists any ignored fields"
          }
        }
      },
      "delete": {
        "operationId": "deleteShare",
        "summary": "DELETE PERMANENTLY — destroys the share and its analytics",
        "description": "IRREVERSIBLE. Removes the share row, its view history, per-slide analytics, recipients, versions and comments, and (for an inline share whose bytes nothing else references) the stored document. This is NOT how you stop a link: to make a URL stop working while keeping everything, use POST /api/share/{slug}/revoke, which POST /api/share/{slug}/unrevoke undoes. If the share has forwards beneath it, a tombstone is kept so those forwards report it as unavailable rather than breaking.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted. `tombstoned: true` when forwards beneath it required a tombstone."
          }
        }
      }
    },
    "/api/share/{slug}/revoke": {
      "post": {
        "operationId": "revokeShare",
        "summary": "Revoke — the link stops working, everything is kept",
        "description": "Reversible. The URL answers 410 for viewers, and forwards beneath it are suspended, but analytics, recipients, versions and content all survive. Undo with /unrevoke. Prefer this over DELETE in almost every case.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked"
          }
        }
      }
    },
    "/api/share/{slug}/unrevoke": {
      "post": {
        "operationId": "unrevokeShare",
        "summary": "Un-revoke — bring a revoked link back",
        "description": "Clears THIS share's revocation. A forward whose own owner revoked it stays revoked: a parent may suspend a child, never un-revoke one on its owner's behalf.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "responses": {
          "200": {
            "description": "Restored"
          }
        }
      }
    },
    "/api/share/{slug}/replace": {
      "post": {
        "operationId": "replaceShareContent",
        "summary": "Update the document behind an existing link",
        "description": "Keeps the URL, password, and analytics. Body: one content source (htmlContent | htmlBase64 | pdfBase64) plus optional { mode: 'announce', note: '...' } to show returning viewers an updated-since-you-read-it banner; omit mode for a silent fix.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Replaced — version incremented"
          }
        }
      }
    },
    "/api/share/{slug}/access": {
      "patch": {
        "operationId": "editShareAccess",
        "summary": "Change who can view: add/remove recipients, viewers, teams",
        "description": "Body: { addRecipients: [emails], removeRecipients: [emails], addTeams: ['@owner/handle'] or addTeamIds: [ids], removeTeams / removeTeamIds, notify: true|false }. Adding a team is SILENT unless notify:true (it can hit many inboxes). Adding a team to a link/recipient-mode share PROMOTES it to account mode — the response's `promoted` object says so; relay it. Removing a recipient revokes their magic link.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/share/{slug}/recipients": {
      "get": {
        "operationId": "listShareRecipients",
        "summary": "Recipients with their personal magic links and view state",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "responses": {
          "200": {
            "description": "OK — { recipients: [{ email, url, registered, invitedAt, lastViewedAt }] }"
          }
        }
      }
    },
    "/api/share/{slug}/invite": {
      "post": {
        "operationId": "inviteToShare",
        "summary": "Email an invitation with the personal link",
        "description": "Body: { emails: [..], message: 'optional personal note shown as a quote', suggestSignup: true, requireAccount: true }. requireAccount makes the personal link identify-only: the document opens after they create a free account with that email and confirm it; re-sending the invite works as a remind-to-sign-up. Unregistered addresses get a signup nudge so the link doesn't read as spam. Suppressed (unsubscribed) addresses are skipped and reported.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK — { notified: [..], skipped: [..] }"
          }
        }
      }
    },
    "/api/share/{slug}/notify": {
      "post": {
        "operationId": "notifyShare",
        "summary": "Re-announce a share to a named audience",
        "description": "Body: { to: ['members'|'teams'|'viewers'|'recipients'|'owner'] } — default ['members'] (teams + allowlist). There is deliberately no 'everyone'. Honors each person's email preferences; response reports notified[] and skipped[] with reasons. Passwords are never re-sent (stored hashed).",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK — { notified: [..], skipped: [{email, why}] }"
          }
        }
      }
    },
    "/api/teams": {
      "get": {
        "operationId": "listTeams",
        "summary": "Teams the caller owns or belongs to",
        "description": "Each team: id, handle, name, ref ('@owner/handle'), members, yourRole, archived, and `ambiguous` (another visible team shares the handle — use the ref or id, never the bare handle).",
        "responses": {
          "200": {
            "description": "OK — { teams: [...] }"
          }
        }
      },
      "post": {
        "operationId": "createTeam",
        "summary": "Create a team (you become owner + first member)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "handle"
                ],
                "properties": {
                  "handle": {
                    "type": "string",
                    "description": "lowercase letters/digits/hyphens, unique per owner"
                  },
                  "name": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "409": {
            "description": "HANDLE_TAKEN"
          }
        }
      }
    },
    "/api/teams/{teamId}": {
      "get": {
        "operationId": "getTeam",
        "summary": "Roster, granted documents, events, pending invites (admins)",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK — team with memberList[], shares[], events[], pendingInvites[] (admin only)"
          },
          "404": {
            "description": "Not visible to you"
          }
        }
      },
      "patch": {
        "operationId": "updateTeam",
        "summary": "Rename, describe, archive or restore",
        "description": "Body: { name?, description?, archived? }. Handles are immutable. Archiving stops all access the team grants; teams are never deleted (archive instead).",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/teams/{teamId}/members": {
      "post": {
        "operationId": "addTeamMember",
        "summary": "Add a member (existing accounts only — see invites for others)",
        "description": "Body: { email | username | userId, role: 'member'|'admin' (default member), notify: true|false (default true — the new member is emailed what the team grants) }. Re-adding an existing member changes NOTHING (alreadyMember: true) — use the role endpoint to promote/demote. Response carries `granted` (documents they just gained) — relay that number.",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK — { granted, notified, alreadyMember?, note }"
          },
          "404": {
            "description": "USER_NOT_FOUND — they need an account; use POST /invites instead"
          }
        }
      }
    },
    "/api/teams/{teamId}/members/{userId}": {
      "patch": {
        "operationId": "setTeamMemberRole",
        "summary": "Promote or demote a member",
        "description": "Body: { role: 'member'|'admin' }. Admins manage membership and settings; the owner is a permanent implicit admin.",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "delete": {
        "operationId": "removeTeamMember",
        "summary": "Remove a member — revokes access on EVERY document the team granted",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Removed"
          },
          "409": {
            "description": "CANNOT_REMOVE_OWNER — archive the team instead"
          }
        }
      }
    },
    "/api/teams/{teamId}/invites": {
      "post": {
        "operationId": "inviteToTeam",
        "summary": "Invite someone WITHOUT an account by email",
        "description": "Body: { email, message? (personal note quoted in the email), role? }. If the address already belongs to a verified account they are ADDED directly (addedDirectly: true). Otherwise an invitation email goes out with a signup link; they join automatically when they sign up and CONFIRM that address (the inviter is emailed when it happens). A pending invite grants nothing. Re-inviting within 24h is throttled (429 INVITE_THROTTLED).",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string"
                  },
                  "message": {
                    "type": "string"
                  },
                  "role": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Invitation sent — { invited, emailSent, expiresAt }"
          },
          "200": {
            "description": "Added directly (account existed)"
          },
          "429": {
            "description": "INVITE_THROTTLED"
          }
        }
      }
    },
    "/api/teams/{teamId}/invites/{inviteId}": {
      "delete": {
        "operationId": "revokeTeamInvite",
        "summary": "Revoke a pending invitation — the emailed link dies",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "inviteId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked"
          }
        }
      }
    },
    "/api/teams/{teamId}/leave": {
      "post": {
        "operationId": "leaveTeam",
        "summary": "Leave a team you are a member of (owners archive instead)",
        "parameters": [
          {
            "name": "teamId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Left — access through this team ended"
          }
        }
      }
    },
    "/api/contacts": {
      "get": {
        "operationId": "listPeople",
        "summary": "People — everyone this account has shared with (derived automatically; owner-scoped)",
        "description": "Recipients, viewers, share notifications, invoice clients, expense approvers, signers and team invitees, captured the moment their address is used. Read-only view of your own history; the first call for an account with no rows backfills it from past shares, invoices, signatures and invitations.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Match on name, email or company."
          },
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "recent",
                "name",
                "count"
              ],
              "default": "recent"
            }
          },
          {
            "name": "source",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "share.recipient",
                "share.viewer",
                "share.notify",
                "invoice",
                "expense.approver",
                "signing",
                "team.invite",
                "manual"
              ]
            },
            "description": "Only people who came through this channel."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 200,
              "maximum": 1000
            }
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "contacts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "email": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string",
                            "nullable": true
                          },
                          "company": {
                            "type": "string",
                            "nullable": true
                          },
                          "notes": {
                            "type": "string",
                            "nullable": true
                          },
                          "account_username": {
                            "type": "string",
                            "nullable": true,
                            "description": "Set when the address belongs to a PitchStation account."
                          },
                          "first_seen": {
                            "type": "string"
                          },
                          "last_seen": {
                            "type": "string"
                          },
                          "seen_count": {
                            "type": "integer"
                          },
                          "last_source": {
                            "type": "string",
                            "enum": [
                              "share.recipient",
                              "share.viewer",
                              "share.notify",
                              "invoice",
                              "expense.approver",
                              "signing",
                              "team.invite",
                              "manual"
                            ]
                          },
                          "last_ref": {
                            "type": "string",
                            "nullable": true,
                            "description": "Share slug, invoice number, expense:<id> or team:<id> the address was last used on."
                          },
                          "sources": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "integer"
                            },
                            "description": "How many times each channel used the address."
                          }
                        }
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "addPerson",
        "summary": "Add someone by hand (same upsert the automatic capture uses)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "company": {
                    "type": "string"
                  },
                  "notes": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "contact": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        },
                        "email": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string",
                          "nullable": true
                        },
                        "company": {
                          "type": "string",
                          "nullable": true
                        },
                        "notes": {
                          "type": "string",
                          "nullable": true
                        },
                        "account_username": {
                          "type": "string",
                          "nullable": true,
                          "description": "Set when the address belongs to a PitchStation account."
                        },
                        "first_seen": {
                          "type": "string"
                        },
                        "last_seen": {
                          "type": "string"
                        },
                        "seen_count": {
                          "type": "integer"
                        },
                        "last_source": {
                          "type": "string",
                          "enum": [
                            "share.recipient",
                            "share.viewer",
                            "share.notify",
                            "invoice",
                            "expense.approver",
                            "signing",
                            "team.invite",
                            "manual"
                          ]
                        },
                        "last_ref": {
                          "type": "string",
                          "nullable": true,
                          "description": "Share slug, invoice number, expense:<id> or team:<id> the address was last used on."
                        },
                        "sources": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "integer"
                          },
                          "description": "How many times each channel used the address."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "BAD_EMAIL"
          }
        }
      }
    },
    "/api/contacts/suggest": {
      "get": {
        "operationId": "suggestPeople",
        "summary": "Typeahead — a few best matches, cheapest shape",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 8
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "people": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "email": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string",
                            "nullable": true
                          },
                          "company": {
                            "type": "string",
                            "nullable": true
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/contacts/backfill": {
      "post": {
        "operationId": "rebuildPeople",
        "summary": "Rebuild the list from past shares, invoices, signatures and invitations (idempotent)",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "touched": {
                      "type": "integer"
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/contacts/{id}": {
      "get": {
        "operationId": "getPerson",
        "summary": "One person",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "contact": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        },
                        "email": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string",
                          "nullable": true
                        },
                        "company": {
                          "type": "string",
                          "nullable": true
                        },
                        "notes": {
                          "type": "string",
                          "nullable": true
                        },
                        "account_username": {
                          "type": "string",
                          "nullable": true,
                          "description": "Set when the address belongs to a PitchStation account."
                        },
                        "first_seen": {
                          "type": "string"
                        },
                        "last_seen": {
                          "type": "string"
                        },
                        "seen_count": {
                          "type": "integer"
                        },
                        "last_source": {
                          "type": "string",
                          "enum": [
                            "share.recipient",
                            "share.viewer",
                            "share.notify",
                            "invoice",
                            "expense.approver",
                            "signing",
                            "team.invite",
                            "manual"
                          ]
                        },
                        "last_ref": {
                          "type": "string",
                          "nullable": true,
                          "description": "Share slug, invoice number, expense:<id> or team:<id> the address was last used on."
                        },
                        "sources": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "integer"
                          },
                          "description": "How many times each channel used the address."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Not yours or not found"
          }
        }
      },
      "patch": {
        "operationId": "editPerson",
        "summary": "Fix a name or company, or add a private note — owner edits win over later automatic captures",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "company": {
                    "type": "string"
                  },
                  "notes": {
                    "type": "string",
                    "description": "Private to the owner."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "contact": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        },
                        "email": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string",
                          "nullable": true
                        },
                        "company": {
                          "type": "string",
                          "nullable": true
                        },
                        "notes": {
                          "type": "string",
                          "nullable": true
                        },
                        "account_username": {
                          "type": "string",
                          "nullable": true,
                          "description": "Set when the address belongs to a PitchStation account."
                        },
                        "first_seen": {
                          "type": "string"
                        },
                        "last_seen": {
                          "type": "string"
                        },
                        "seen_count": {
                          "type": "integer"
                        },
                        "last_source": {
                          "type": "string",
                          "enum": [
                            "share.recipient",
                            "share.viewer",
                            "share.notify",
                            "invoice",
                            "expense.approver",
                            "signing",
                            "team.invite",
                            "manual"
                          ]
                        },
                        "last_ref": {
                          "type": "string",
                          "nullable": true,
                          "description": "Share slug, invoice number, expense:<id> or team:<id> the address was last used on."
                        },
                        "sources": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "integer"
                          },
                          "description": "How many times each channel used the address."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Not yours or not found"
          }
        }
      },
      "delete": {
        "operationId": "forgetPerson",
        "summary": "Forget someone — list only; nothing they can open changes, and the next share re-adds them",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Removed"
          },
          "404": {
            "description": "Not yours or not found"
          }
        }
      }
    }
  }
}
