{"openapi":"3.1.0","info":{"title":"PilotPM API","version":"1.0.0","description":"Public HTTP API for pushing signals into PilotPM — agent-observed events (POST /api/v1/events) and customer profile upserts (POST /api/v1/customers/upsert) — and for pulling per-account rollups (GET /api/v1/accounts/{hubspotCompanyId}/…) into external CS tools. All endpoints authenticate with a per-workspace agent token sent in the x-workspace-agent-token header. The write endpoints are also reachable at their original unversioned paths (/api/events, /api/customers/upsert) — legacy aliases kept for backward compatibility.","contact":{"email":"support@pilotpm.ai"}},"servers":[{"url":"https://app.pilotpm.ai","description":"Production"}],"components":{"securitySchemes":{"workspaceAgentToken":{"type":"apiKey","in":"header","name":"x-workspace-agent-token","description":"Per-workspace agent token (format: wsk_ followed by 64 hex characters). Created by a workspace admin in Settings → Agent tokens; shown once at creation; revocable at any time. Each token is bound to exactly one workspace — the workspace is derived from the token, never from the request body. Tokens carry scopes chosen at mint time: 'events:write' (the write endpoints) and 'read' (the GET /api/v1/accounts endpoints). The default mint has both; a read-only token has just 'read'. Tokens minted before scopes existed have full access. Calling an endpoint without the required scope returns 403 { error: 'forbidden', missing_scope: '…' }."}}},"paths":{"/api/v1/events":{"post":{"operationId":"ingestEvent","summary":"Ingest an event","description":"Push a signal observed by an out-of-process agent into the workspace's event stream (themes, competitive intel, delivery risks, call briefs). The target workspace is bound from the token — a workspace_id in the body is ignored, as is any other unknown top-level field. Request bodies are capped at 65536 bytes (64 KB) and rejected with 413 before parsing. Rate limit: 100 requests per 60-second sliding window per token; excess requests get 429 with a Retry-After header (seconds). Requires the events:write scope. Also reachable at the legacy unversioned path /api/events (identical behavior).","security":[{"workspaceAgentToken":["events:write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["agent","type","title","citations"],"properties":{"agent":{"type":"string","enum":["intake-analyst","delivery-pm","roadmap-strategist","competitive-researcher","call-brief"],"description":"Which agent persona produced this event. Must be one of the five built-in personas."},"type":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form event type slug, e.g. \"theme_spike\" or \"competitor_launch\". Used for grouping and filtering."},"severity":{"type":"string","enum":["info","watch","alert"],"default":"info","description":"Triage level. Defaults to info if omitted."},"title":{"type":"string","minLength":1,"maxLength":500,"description":"Human-readable headline for the event."},"summary":{"type":"string","maxLength":1000,"description":"Optional longer summary."},"data":{"type":"object","default":{},"additionalProperties":true,"description":"Structured payload; shape varies per agent. The well-known fields below have enforced caps. Extra keys are accepted (passthrough).","properties":{"theme":{"type":"string","maxLength":200},"customer_name":{"type":"string","maxLength":200},"customer_count":{"type":"integer","minimum":0,"maximum":100000},"source_count":{"type":"integer","minimum":0,"maximum":100000},"severity":{"type":"string","maxLength":40,"description":"Agent-specific severity label inside the payload (distinct from the top-level severity enum)."},"competitor_name":{"type":"string","maxLength":200},"implication":{"type":"string","maxLength":2000},"estimated_arr_at_risk":{"type":"number","minimum":0,"maximum":1000000000000000}}},"citations":{"type":"array","minItems":1,"maxItems":50,"description":"Required — every event must cite its sources. At least one citation.","items":{"type":"object","required":["kind"],"additionalProperties":true,"properties":{"kind":{"type":"string","minLength":1,"maxLength":40,"description":"Source kind, e.g. \"conversation\", \"slack\", \"ticket\", \"url\"."},"url":{"type":"string","format":"uri","maxLength":2048,"description":"Link to the source (must be a valid URL)."},"ts":{"type":"string","maxLength":40,"description":"Source-native timestamp or message ts."},"id":{"type":"string","maxLength":200,"description":"Source-native identifier."}}}},"occurred_at":{"type":"string","format":"date-time","description":"When the event happened, ISO 8601 UTC with a Z suffix (e.g. \"2026-07-04T09:30:00Z\"; timezone offsets are rejected). Defaults to receipt time."}}},"example":{"agent":"intake-analyst","type":"theme_spike","severity":"watch","title":"Login failures spiking for EU users","summary":"14 customers reported OAuth sign-in loops in the last 24 hours.","data":{"theme":"authentication","customer_count":14},"citations":[{"kind":"conversation","id":"conv_8f3a"}],"occurred_at":"2026-07-04T09:30:00Z"}}}},"responses":{"201":{"description":"Event stored.","content":{"application/json":{"schema":{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid","description":"ID of the created event."}}},"example":{"id":"5b1e5f0a-9c1d-4a6f-8f2e-3d7c9a1b2c3d"}}}},"400":{"description":"Body is not valid JSON.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"invalid JSON"}}}},"401":{"description":"Missing, malformed, revoked, or unknown token.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unauthorized"}}}},"403":{"description":"Token authenticated but lacks the events:write scope (read-only tokens cannot write).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"missing_scope":{"type":"string"}}},"example":{"error":"forbidden","missing_scope":"events:write"}}}},"404":{"description":"The workspace bound to the token no longer exists (deleted after the token was created).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"workspace not found"}}}},"413":{"description":"Request body exceeds 65536 bytes (64 KB).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"body too large (max 65536 bytes)"}}}},"422":{"description":"Body failed schema validation. The issues array lists each violation (Zod issue format: code, path, message).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"issues":{"type":"array","items":{"type":"object","additionalProperties":true}}}},"example":{"error":"validation failed","issues":[{"code":"too_small","path":["citations"],"message":"citations required — every event must cite its source"}]}}}},"429":{"description":"Rate limit exceeded (100 requests per 60s sliding window per token). Retry after the number of seconds in the Retry-After header.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"rate limit exceeded"}}}}}}},"/api/v1/customers/upsert":{"post":{"operationId":"upsertCustomer","summary":"Upsert a customer","description":"Idempotent upsert of a customer record, keyed on (workspace, external_id). Call it when an end-user signs in, updates their profile, or changes plan; the resulting attributes render in the inbox side panel for every conversation linked to that customer. Attributes merge shallowly (new keys win, existing keys you don't send are kept). Attribute keys are lowercased; keys longer than 60 chars are dropped; PII-shaped keys (email, name, phone, address) are dropped from attributes — use the dedicated email / name fields instead. Values may be strings (truncated to 1000 chars), numbers, booleans, null, or arrays of primitives (capped at 20 items); nested objects are silently skipped. At most 50 attributes are stored per call. Requires the events:write scope. Also reachable at the legacy unversioned path /api/customers/upsert (identical behavior).","security":[{"workspaceAgentToken":["events:write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["external_id"],"properties":{"external_id":{"type":"string","minLength":1,"maxLength":240,"description":"Your system's stable ID for this user. Leading/trailing whitespace is trimmed; the trimmed value must be non-empty."},"email":{"type":"string","description":"Optional but recommended — enables inbound-email matching. Normalized to lowercase; stored encrypted."},"name":{"type":"string","description":"Optional display name. Stored encrypted."},"attributes":{"type":"object","additionalProperties":true,"description":"Flat bag of profile attributes (plan tier, platform, country, renewal risk, …). See the merge and sanitization rules in the endpoint description."}}},"example":{"external_id":"user_abc123","email":"alice@example.com","name":"Alice Wonder","attributes":{"platform":"iOS","tier":"Premium","country":"Vietnam","os_version":"17.4","renewal_risk":"medium"}}}}},"responses":{"200":{"description":"Upsert succeeded. created is true when a new customer row was inserted, false when an existing one was updated.","content":{"application/json":{"schema":{"type":"object","required":["ok","id","created"],"properties":{"ok":{"type":"boolean"},"id":{"type":"string","format":"uuid","description":"PilotPM's ID for the customer row."},"created":{"type":"boolean"}}},"example":{"ok":true,"id":"0d7f3b2a-1c4e-4f6a-9b8d-2e5f7a9c1b3d","created":true}}}},"400":{"description":"Body is not valid JSON (invalid_body), or external_id is missing / empty after trimming (missing_external_id).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","missing_external_id"]}}},"example":{"error":"missing_external_id"}}}},"401":{"description":"Missing, malformed, revoked, or unknown token.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unauthorized"}}}},"403":{"description":"Token authenticated but lacks the events:write scope (read-only tokens cannot write).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"missing_scope":{"type":"string"}}},"example":{"error":"forbidden","missing_scope":"events:write"}}}},"500":{"description":"Upsert failed server-side (also returned when external_id exceeds 240 characters).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"upsert_failed"}}}}}}},"/api/v1/accounts/{hubspotCompanyId}/issues":{"get":{"operationId":"listAccountIssues","summary":"List an account's product issues","description":"The account's product ISSUES: conversations whose latest inbound message the classifier tagged bug_report (the taxonomy's explicit defect bucket — crashes, errors, wrong results). Support/ops categories (billing, login, activation) are deliberately excluded. Accounts are keyed by HubSpot company id — the same key ChurnZero uses as Account External ID. Workspace is bound from the token; a company id belonging to another tenant returns 404 here. Spam and merged-duplicate conversations are excluded. Newest first, capped at 50 rows. Requires the read scope. Rate limit: 60 requests per 60-second sliding window per token.","security":[{"workspaceAgentToken":["read"]}],"parameters":[{"name":"hubspotCompanyId","in":"path","required":true,"example":"144818714","schema":{"type":"string","minLength":1,"maxLength":64},"description":"The account's HubSpot company id — the same value external CS tools (e.g. ChurnZero) use as the Account External ID. Up to 64 chars of [A-Za-z0-9_-]; anything else returns 400 invalid_company_id."},{"name":"status","in":"query","required":false,"example":"open","schema":{"type":"string","enum":["open","all"],"default":"open"},"description":"open (default) = conversations not yet resolved/closed (status open, awaiting_customer, or snoozed). all = include resolved and closed too. Unrecognized values fall back to open."}],"responses":{"200":{"description":"The account's issue conversations.","content":{"application/json":{"schema":{"type":"object","required":["ok","account","data"],"properties":{"ok":{"type":"boolean"},"account":{"type":"object","required":["hubspot_company_id"],"properties":{"hubspot_company_id":{"type":"string"}}},"data":{"type":"array","maxItems":50,"items":{"type":"object","required":["id","subject","category","status","created_at","resolved_at"],"properties":{"id":{"type":"string","format":"uuid","description":"PilotPM conversation id."},"subject":{"type":"string","description":"Conversation subject line (may be null on subject-less channels). No message bodies and no customer emails/PII are ever returned."},"category":{"type":"string","description":"The classifier tag of the conversation's latest inbound message (bug_report for issues; feature_or_product for requests)."},"status":{"type":"string","enum":["open","awaiting_customer","snoozed","resolved","closed"],"description":"Conversation status at read time."},"created_at":{"type":"string","format":"date-time","description":"When the conversation started (ISO 8601 UTC)."},"resolved_at":{"type":"string","format":"date-time","description":"When it resolved (ISO 8601 UTC); null while open."}}},"description":"Newest first; capped at 50 rows."}}},"example":{"ok":true,"account":{"hubspot_company_id":"144818714"},"data":[{"id":"5b1e5f0a-9c1d-4a6f-8f2e-3d7c9a1b2c3d","subject":"Scores wrong after the last update","category":"bug_report","status":"open","created_at":"2026-07-02T08:11:24.000Z","resolved_at":null}]}}}},"400":{"description":"Malformed hubspotCompanyId path segment.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"invalid_company_id"}}}},"401":{"description":"Missing, malformed, revoked, or unknown token.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unauthorized"}}}},"403":{"description":"Token authenticated but lacks the read scope.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"missing_scope":{"type":"string"}}},"example":{"error":"forbidden","missing_scope":"read"}}}},"404":{"description":"Unknown company: this workspace has never had a conversation for that HubSpot company id. (An account whose conversations are all resolved still returns 200 with empty/zero data.)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unknown_company"}}}},"429":{"description":"Rate limit exceeded (60 requests per 60s sliding window per token, shared across the three account endpoints). Retry after the number of seconds in the Retry-After header.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"rate limit exceeded"}}}}}}},"/api/v1/accounts/{hubspotCompanyId}/requests":{"get":{"operationId":"listAccountRequests","summary":"List an account's feature requests","description":"The account's feature REQUESTS: conversations whose latest inbound message the classifier tagged feature_or_product. Caveat: that category also covers general product / how-it-works questions (the taxonomy doesn't split them), so treat these as an upper bound on true feature asks. Same keying, auth, exclusions, ordering, cap, status filter, and rate limit as the issues endpoint.","security":[{"workspaceAgentToken":["read"]}],"parameters":[{"name":"hubspotCompanyId","in":"path","required":true,"example":"144818714","schema":{"type":"string","minLength":1,"maxLength":64},"description":"The account's HubSpot company id — the same value external CS tools (e.g. ChurnZero) use as the Account External ID. Up to 64 chars of [A-Za-z0-9_-]; anything else returns 400 invalid_company_id."},{"name":"status","in":"query","required":false,"example":"open","schema":{"type":"string","enum":["open","all"],"default":"open"},"description":"open (default) = conversations not yet resolved/closed (status open, awaiting_customer, or snoozed). all = include resolved and closed too. Unrecognized values fall back to open."}],"responses":{"200":{"description":"The account's request conversations.","content":{"application/json":{"schema":{"type":"object","required":["ok","account","data"],"properties":{"ok":{"type":"boolean"},"account":{"type":"object","required":["hubspot_company_id"],"properties":{"hubspot_company_id":{"type":"string"}}},"data":{"type":"array","maxItems":50,"items":{"type":"object","required":["id","subject","category","status","created_at","resolved_at"],"properties":{"id":{"type":"string","format":"uuid","description":"PilotPM conversation id."},"subject":{"type":"string","description":"Conversation subject line (may be null on subject-less channels). No message bodies and no customer emails/PII are ever returned."},"category":{"type":"string","description":"The classifier tag of the conversation's latest inbound message (bug_report for issues; feature_or_product for requests)."},"status":{"type":"string","enum":["open","awaiting_customer","snoozed","resolved","closed"],"description":"Conversation status at read time."},"created_at":{"type":"string","format":"date-time","description":"When the conversation started (ISO 8601 UTC)."},"resolved_at":{"type":"string","format":"date-time","description":"When it resolved (ISO 8601 UTC); null while open."}}},"description":"Newest first; capped at 50 rows."}}},"example":{"ok":true,"account":{"hubspot_company_id":"144818714"},"data":[{"id":"0d7f3b2a-1c4e-4f6a-9b8d-2e5f7a9c1b3d","subject":"Can we get an export to CSV?","category":"feature_or_product","status":"resolved","created_at":"2026-06-21T14:03:00.000Z","resolved_at":"2026-06-23T09:45:12.000Z"}]}}}},"400":{"description":"Malformed hubspotCompanyId path segment.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"invalid_company_id"}}}},"401":{"description":"Missing, malformed, revoked, or unknown token.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unauthorized"}}}},"403":{"description":"Token authenticated but lacks the read scope.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"missing_scope":{"type":"string"}}},"example":{"error":"forbidden","missing_scope":"read"}}}},"404":{"description":"Unknown company: this workspace has never had a conversation for that HubSpot company id. (An account whose conversations are all resolved still returns 200 with empty/zero data.)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unknown_company"}}}},"429":{"description":"Rate limit exceeded (60 requests per 60s sliding window per token, shared across the three account endpoints). Retry after the number of seconds in the Retry-After header.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"rate limit exceeded"}}}}}}},"/api/v1/accounts/{hubspotCompanyId}/summary":{"get":{"operationId":"getAccountSummary","summary":"Get an account's counts-only summary","description":"Counts-only rollup for the account, computed in one query: open/total product issues (bug_report), open/total feature requests (feature_or_product), open conversations of any category, and the timestamp of the last message in either direction (last_contact_at). Open = status open, awaiting_customer, or snoozed. Spam and merged-duplicate conversations are excluded from every count. Same keying, auth, and rate limit as the list endpoints.","security":[{"workspaceAgentToken":["read"]}],"parameters":[{"name":"hubspotCompanyId","in":"path","required":true,"example":"144818714","schema":{"type":"string","minLength":1,"maxLength":64},"description":"The account's HubSpot company id — the same value external CS tools (e.g. ChurnZero) use as the Account External ID. Up to 64 chars of [A-Za-z0-9_-]; anything else returns 400 invalid_company_id."}],"responses":{"200":{"description":"The account's summary counts.","content":{"application/json":{"schema":{"type":"object","required":["ok","account","data"],"properties":{"ok":{"type":"boolean"},"account":{"type":"object","required":["hubspot_company_id"],"properties":{"hubspot_company_id":{"type":"string"}}},"data":{"type":"object","required":["issues_open","issues_total","requests_open","requests_total","open_conversations","last_contact_at"],"properties":{"issues_open":{"type":"integer","minimum":0},"issues_total":{"type":"integer","minimum":0},"requests_open":{"type":"integer","minimum":0},"requests_total":{"type":"integer","minimum":0},"open_conversations":{"type":"integer","minimum":0,"description":"Open conversations of ANY category, not just issues + requests."},"last_contact_at":{"type":"string","format":"date-time","description":"Latest message in either direction (ISO 8601 UTC); null when the account has conversations but no messages."}}}}},"example":{"ok":true,"account":{"hubspot_company_id":"144818714"},"data":{"issues_open":2,"issues_total":9,"requests_open":1,"requests_total":4,"open_conversations":5,"last_contact_at":"2026-07-03T18:22:41.000Z"}}}}},"400":{"description":"Malformed hubspotCompanyId path segment.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"invalid_company_id"}}}},"401":{"description":"Missing, malformed, revoked, or unknown token.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unauthorized"}}}},"403":{"description":"Token authenticated but lacks the read scope.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"missing_scope":{"type":"string"}}},"example":{"error":"forbidden","missing_scope":"read"}}}},"404":{"description":"Unknown company: this workspace has never had a conversation for that HubSpot company id. (An account whose conversations are all resolved still returns 200 with empty/zero data.)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"unknown_company"}}}},"429":{"description":"Rate limit exceeded (60 requests per 60s sliding window per token, shared across the three account endpoints). Retry after the number of seconds in the Retry-After header.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"rate limit exceeded"}}}}}}}}}