openapi: 3.0.3
info:
  title: Poplar API
  version: 0.1.0
  description: |
    Programmatic access to Poplar — workspace provisioning, knowledge ingest,
    cross-workspace analytics. Same routes the Poplar web UI uses; Bearer auth
    layers public access on top.

    **Auth:** `Authorization: Bearer pop_live_<prefix>_<secret>`. Generate keys
    at Settings → API Keys.

    **Workspace scoping:** every workspace-scoped call needs `X-Workspace-Id`
    when called via Bearer (cookie auth uses the active workspace cookie).
    The key's user must have the required role in that workspace, and the
    workspace must be in the key's allow-list (or the allow-list is null).
  contact:
    name: Poplar Support
    email: support@usepoplar.com

servers:
  - url: https://usepoplar.com
    description: Production

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: pop_live_<prefix>_<secret>

  parameters:
    WorkspaceIdHeader:
      name: X-Workspace-Id
      in: header
      required: true
      schema: { type: string, format: uuid }
      description: Target workspace UUID. Required for workspace-scoped Bearer calls.

  schemas:
    Workspace:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        timezone: { type: string, example: America/New_York }
        logo_url: { type: string, nullable: true }
        settings: { type: object }
        role:
          type: string
          enum: [owner, admin, member]
          description: Calling user's role in this workspace (list endpoint only)

    Error:
      type: object
      required: [error, code]
      properties:
        error: { type: string, example: "missing scope: workspaces:create" }
        code:
          type: string
          enum:
            - VALIDATION
            - MISSING_SCOPE
            - WORKSPACE_NOT_IN_ALLOWLIST
            - NOT_MEMBER
            - INSUFFICIENT_ROLE
            - MISSING_WORKSPACE_HEADER
            - NO_ACTIVE_WORKSPACE
            - WORKSPACE_LIMIT_REACHED
            - MEMBER_LIMIT_REACHED
            - RATE_LIMITED
            - INVALID_WORKSPACE
            - NOT_FOUND
            - DB
            - INTERNAL

    ConnectionSummary:
      type: object
      description: Social account a post was published to.
      properties:
        id: { type: string, format: uuid, nullable: true }
        provider: { type: string, nullable: true, example: linkedin }
        type:
          type: string
          enum: [personal, page, unknown]
        name: { type: string, nullable: true, example: "Sean Sun" }
        avatar: { type: string, nullable: true }
        provider_user_id:
          type: string
          nullable: true
          example: "urn:li:person:abc"

    PostPerformanceItem:
      type: object
      properties:
        workspace_id: { type: string, format: uuid }
        workspace_name: { type: string }
        post_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        connection: { $ref: '#/components/schemas/ConnectionSummary' }
        published_at: { type: string, format: date-time }
        content: { type: string, description: "First 1500 chars of post body" }
        likes: { type: integer, nullable: true }
        comments: { type: integer, nullable: true }
        shares: { type: integer, nullable: true }
        impressions: { type: integer, nullable: true }
        engagement_total: { type: integer, nullable: true }
        snapshot_date: { type: string, format: date, nullable: true }
        features: { type: object, nullable: true, description: "LLM-classified DNA (hook type, structure, topic, intent)" }
        feature_status:
          type: string
          nullable: true
          enum: [tagged, skipped_non_substantive, error, null]

  responses:
    Unauthorized:
      description: Missing/invalid/revoked/expired Bearer (or no session cookie)
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: "Not authenticated" }

    Forbidden:
      description: Auth OK but lacks scope / role / allow-list
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    BadRequest:
      description: Malformed body or query
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                properties:
                  window: { type: string, enum: [minute, hour] }
                  limit: { type: integer }
                  retry_after_seconds: { type: integer }

paths:

  /api/workspaces:
    get:
      summary: List workspaces
      description: Active workspaces accessible to the key. Filtered by allow-list when set.
      security: [{ bearerAuth: [workspaces:read] }]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  workspaces:
                    type: array
                    items: { $ref: '#/components/schemas/Workspace' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      summary: Create a workspace
      description: |
        Creates a new workspace; the calling user becomes its owner. The
        returned `id` is what you'll use as `X-Workspace-Id` for subsequent
        calls. Optional `logo_url` lets you set the logo at creation time
        (must be a publicly hosted image URL).
      security: [{ bearerAuth: [workspaces:create] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: "Acme Corp" }
                description: { type: string, nullable: true }
                timezone:
                  type: string
                  nullable: true
                  example: America/New_York
                  description: IANA timezone. Invalid values are silently dropped (workspace gets default).
                logo_url:
                  type: string
                  nullable: true
                  example: "https://cdn.acme.com/logo.png"
                  description: Publicly hosted logo image URL.
      responses:
        '200':
          description: Workspace created
          content:
            application/json:
              schema:
                type: object
                properties:
                  workspace: { $ref: '#/components/schemas/Workspace' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Plan limit hit (`WORKSPACE_LIMIT_REACHED`) OR missing scope
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      currentCount: { type: integer }
                      maxAllowed: { type: integer }

  /api/workspaces/settings:
    get:
      summary: Read workspace settings
      security: [{ bearerAuth: [workspaces:read] }]
      parameters:
        - $ref: '#/components/parameters/WorkspaceIdHeader'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  settings: { type: object }
    post:
      summary: Update workspace
      description: Updatable fields - name, logo_url, timezone, settings JSONB.
      security: [{ bearerAuth: [workspaces:update] }]
      parameters:
        - $ref: '#/components/parameters/WorkspaceIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                logo_url: { type: string, nullable: true }
                timezone: { type: string }
                settings: { type: object }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /api/invitations/send:
    post:
      summary: Invite a member
      description: |
        Sends a one-click accept email (Resend) to the invitee. Same email
        template as the UI flow.
      security: [{ bearerAuth: [members:invite] }]
      parameters:
        - $ref: '#/components/parameters/WorkspaceIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                role:
                  type: string
                  enum: [admin, member]
                  default: member
                agentAccess:
                  type: boolean
                  nullable: true
                  description: |
                    If null (omitted), defaults from role (admin→true, member→false).
      responses:
        '200':
          description: Invitation sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sent: { type: boolean }
                  emailEnabled: { type: boolean }
                  message: { type: string }

  /api/knowledge/items:
    post:
      summary: Push a knowledge item
      description: |
        Push a transcript, document, link, or page into the workspace's
        knowledge base — the same store the AI agent reads when drafting
        posts. Pass `metadata.external_id` to make the push idempotent.
      security: [{ bearerAuth: [knowledge:write] }]
      parameters:
        - $ref: '#/components/parameters/WorkspaceIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, title]
              properties:
                type:
                  type: string
                  enum: [document, link, website, fireflies_transcript, notion_page, notion_database, granola_meeting]
                title: { type: string }
                description: { type: string, nullable: true }
                content_type: { type: string, default: text/plain }
                source_url: { type: string, nullable: true }
                text_content: { type: string, description: "Inline content. Goes into metadata.text_content." }
                metadata:
                  type: object
                  description: |
                    Free-form. `metadata.external_id` (string) enables idempotency —
                    re-pushing the same (workspace, type, external_id) updates
                    the existing row instead of inserting.
      responses:
        '200':
          description: Pushed (created or updated)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  id: { type: string, format: uuid }
                  workspace_id: { type: string, format: uuid }
                  type: { type: string }
                  title: { type: string }
                  created:
                    type: boolean
                    description: true = new row; false = idempotent update via external_id

  /api/v1/workspaces/bulk:
    post:
      summary: Create up to 50 workspaces in a single call
      description: |
        Processes sequentially so plan-limit checks stay accurate. Partial
        failures don't fail the request — each input gets a per-item result.
        Plan-limit hit on item N stops processing and marks items N+1..end
        as `SKIPPED_AFTER_PLAN_LIMIT`.
      security: [{ bearerAuth: [workspaces:create] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspaces]
              properties:
                workspaces:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: object
                    required: [name]
                    properties:
                      name: { type: string }
                      description: { type: string, nullable: true }
                      timezone: { type: string, nullable: true }
                      logo_url: { type: string, nullable: true }
      responses:
        '200':
          description: Bulk processed (may include partial failures)
          content:
            application/json:
              schema:
                type: object
                properties:
                  requested: { type: integer }
                  created: { type: integer }
                  failed: { type: integer }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        requested_name: { type: string, nullable: true }
                        ok: { type: boolean }
                        workspace: { $ref: '#/components/schemas/Workspace' }
                        error:
                          type: object
                          properties:
                            code: { type: string }
                            message: { type: string }

  /api/v1/knowledge-items/from-url:
    post:
      summary: Push knowledge from a URL (one-shot crawl)
      description: |
        Server crawls the URL via Exa (handles LinkedIn, JS-rendered pages,
        paywalls), extracts the text, and creates the knowledge_item with
        content already populated. Idempotent on `metadata.external_id`.
      security: [{ bearerAuth: [knowledge:write] }]
      parameters:
        - $ref: '#/components/parameters/WorkspaceIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                title: { type: string, nullable: true }
                external_id: { type: string, nullable: true }

  /api/v1/knowledge-items/upload-url:
    post:
      summary: Upload a file (Step 1 of 2) — parsing is automatic
      description: |
        Two-step file upload flow with **automatic parsing**:

        1. POST filename + content_type here → returns item_id and a
           one-time upload URL.
        2. POST the file binary to that upload URL as multipart/form-data
           (file under `file` field). Server returns 204 on success.
           Parsing kicks off automatically — text is extracted within
           5–60 seconds and persisted so the AI agent can use it.

        Optionally, call POST /api/v1/knowledge-items/{id}/parse after
        step 2 to wait synchronously and get the parsed text back in the
        response (useful if you want to generate a post immediately).

        Supported file types: `application/pdf` (up to 20 MB) and
        `image/*` (up to 5 MB). Idempotent on `metadata.external_id`.
      security: [{ bearerAuth: [knowledge:write] }]
      parameters:
        - $ref: '#/components/parameters/WorkspaceIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, content_type]
              properties:
                filename: { type: string, example: "acme-call.pdf" }
                content_type: { type: string, example: "application/pdf" }
                title: { type: string, nullable: true }
                external_id: { type: string, nullable: true }
      responses:
        '200':
          description: Item reserved + presigned URL issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  item_id: { type: string, format: uuid }
                  created: { type: boolean }
                  upload:
                    type: object
                    properties:
                      method: { type: string, example: POST }
                      url: { type: string }
                      fields: { type: object, additionalProperties: { type: string } }
                      file_field: { type: string, example: file }
                      max_size_bytes: { type: integer }
                      expires_in_seconds: { type: integer }
                  parsing:
                    type: object
                    description: How parsing will happen after the upload lands.
                    properties:
                      mode: { type: string, example: automatic }
                      note: { type: string }

  /api/v1/knowledge-items/{id}/parse:
    post:
      summary: Synchronously parse an uploaded file (optional)
      description: |
        Optional. Parsing already runs automatically after upload — call
        this only if you want to wait synchronously and get the extracted
        text back in the response (e.g. to generate a post immediately
        off the uploaded file).

        Dispatches based on content_type:
        - `application/pdf` → text extraction
        - `image/*` → OCR + a short description so the AI agent has context

        Idempotent: calling twice returns the cached result instantly.
      security: [{ bearerAuth: [knowledge:write] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: item_id from the upload-url response
      responses:
        '200':
          description: Parsed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  cached: { type: boolean }
                  item_id: { type: string, format: uuid }
                  title: { type: string }
                  type: { type: string }
                  content_type: { type: string }
                  text_length: { type: integer }
                  parsed: { type: object, additionalProperties: true }

  /api/v1/analytics/posts:
    get:
      summary: Cross-workspace post performance
      description: |
        Published-post performance across the workspaces the key can see.
        Cursor pagination over (published_at DESC, id DESC).
      security: [{ bearerAuth: [analytics:read] }]
      parameters:
        - name: workspace_id
          in: query
          schema: { type: string, format: uuid }
          description: Optional — restrict to one workspace (must be in allow-list and you must be a member).
        - name: connection_id
          in: query
          schema: { type: string, format: uuid }
          description: Optional — restrict to one social account.
        - name: since
          in: query
          schema: { type: string, format: date }
        - name: until
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque cursor from previous `next_cursor`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/PostPerformanceItem' }
                  next_cursor: { type: string, nullable: true }
                  has_more: { type: boolean }

tags:
  - name: Workspaces
  - name: Members
  - name: Knowledge
  - name: Analytics
