openapi: 3.0.3
info:
  title: Publora API
  description: |
    Affordable REST API for scheduling and publishing social media posts
    across X/Twitter, LinkedIn, Instagram, Threads, TikTok, YouTube,
    Facebook, Bluesky, Mastodon, and Telegram.

    All plans include full API access. Starting at $5.40/month (yearly) or $9/month.
    14-day free trial, no credit card needed.

    ## Workspace API
    The Workspace API allows you to manage multiple users under a single account.
    **To enable Workspace access, please contact Publora support at serge@publora.com.**
  version: "1.0.0"
  contact:
    email: serge@publora.com
    url: https://publora.com
servers:
  - url: https://api.publora.com/api/v1
    description: Production
security:
  - ApiKeyAuth: []

tags:
  - name: Posts
    description: Create, update, delete, and list posts
  - name: Connections
    description: Manage platform connections
  - name: Media
    description: Upload media files
  - name: LinkedIn Analytics
    description: LinkedIn-specific analytics and interactions
  - name: Webhooks
    description: Manage webhook endpoints for real-time notifications
  - name: Workspace
    description: "Manage multiple users (requires Workspace access - contact support)"
  - name: Logs
    description: Post logs and debugging

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-publora-key
      description: "API key from Settings > API Keys. Format: sk_timestamp.hexstring"

  parameters:
    XPubloraClient:
      name: x-publora-client
      in: header
      required: false
      description: "Optional client identifier accepted by every API-key-authenticated operation. Any non-empty value is preserved; `api` is used when absent. Setting `mcp` triggers the MCP access entitlement check."
      schema:
        type: string

  schemas:
    YouTubePlaylistReference:
      type: object
      nullable: true
      additionalProperties: false
      default: { id: "", platformId: "" }
      description: "Reference to a YouTube playlist for platformSettings.youtube.playlist. Send empty strings for both fields to clear the playlist."
      properties:
        id:
          type: string
          nullable: true
          default: ""
          description: "YouTube playlist id"
          example: "PLxxxxxxxx"
        platformId:
          type: string
          nullable: true
          default: ""
          description: "Compound YouTube platform id from platforms; must match the single selected YouTube channel"
          example: "youtube-UCxxxxxxxx"
    YouTubeThumbnailReference:
      type: object
      nullable: true
      additionalProperties: false
      description: "Reference to a Publora-tracked custom thumbnail for platformSettings.youtube.thumbnail. Send an empty url to clear the thumbnail. Set via update-post only (the upload requires a postGroupId)."
      properties:
        mediaId:
          type: string
          nullable: true
          description: "Publora media id returned by the YouTube thumbnail upload endpoint"
          example: "665f..."
        id:
          type: string
          nullable: true
          description: "REST alias for mediaId"
        url:
          type: string
          nullable: true
          description: "Publora-hosted thumbnail URL returned by the upload endpoint"
          example: "https://media.publora.com/..."
        path:
          type: string
          nullable: true
          description: "REST alias for url"
    PlatformSettingsInput:
      type: object
      additionalProperties: false
      description: "Per-platform settings for tiktok, instagram, youtube, threads, telegram, and linkedin. Unknown platform or nested paths are rejected with 400 PLATFORM_SETTING_UNKNOWN. Merged with defaults on create-post and with existing settings on update-post (omitted fields are preserved)."
      properties:
        instagram:
          type: object
          additionalProperties: false
          properties:
            videoType:
              type: string
              enum: [REELS, STORIES]
              default: REELS
              description: "How videos are published"
            coverUrl:
              type: string
              description: "Custom Reels cover image (alias: cover_url). Publicly accessible http(s) URL to a JPEG — Instagram fetches it server-side at publish time. Empty string clears the custom cover. Invalid URLs are rejected with 400. Ignored for Stories and image posts."
              example: "https://cdn.example.com/covers/reel-cover.jpg"
            cover_url:
              type: string
              description: "REST alias for coverUrl; normalized to coverUrl before storage"
              example: "https://cdn.example.com/covers/reel-cover.jpg"
            shareToFeed:
              type: boolean
              description: "For Reels, false prevents sharing the Reel to the main feed"
        tiktok:
          type: object
          additionalProperties: false
          properties:
            viewerSetting:
              type: string
              enum: [PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY]
              default: PUBLIC_TO_EVERYONE
            allowComments:
              type: boolean
              default: true
            allowDuet:
              type: boolean
              default: false
            allowStitch:
              type: boolean
              default: false
            commercialContent:
              type: boolean
              default: false
            brandOrganic:
              type: boolean
              default: false
            brandedContent:
              type: boolean
              default: false
        youtube:
          type: object
          additionalProperties: false
          properties:
            privacy:
              type: string
              enum: [private, public, unlisted]
              default: public
            title:
              type: string
              default: ""
            madeForKids:
              type: boolean
              default: false
            tags:
              description: "Video tags. REST accepts an array or a comma-separated string and caps the normalized combined tag budget at 500 characters."
              oneOf:
                - type: array
                  items:
                    type: string
                - type: string
            categoryId:
              type: string
              description: "Optional YouTube snippet.categoryId"
            playlist:
              $ref: "#/components/schemas/YouTubePlaylistReference"
            thumbnail:
              $ref: "#/components/schemas/YouTubeThumbnailReference"
        threads:
          type: object
          additionalProperties: false
          properties:
            replyControl:
              type: string
              enum: [everyone, accounts_you_follow, mentioned_only, ""]
              default: ""
        telegram:
          type: object
          additionalProperties: false
          properties:
            disableNotification:
              type: boolean
              default: false
            disableWebPagePreview:
              type: boolean
              default: false
            protectContent:
              type: boolean
              default: false
        linkedin:
          type: object
          additionalProperties: false
          description: "LinkedIn repost (reshare) settings. When a repost is enabled, the group's content becomes the reshare commentary (3,000-char limit) and media is forbidden — scheduling a repost group with media fails validation with code MEDIA_TYPE_NOT_SUPPORTED."
          properties:
            repostEnabled:
              type: boolean
              default: false
            repostParentUrn:
              type: string
              default: ""
              description: "Canonical urn:li:share:* or urn:li:ugcPost:* parent. Required when repostEnabled is true; an empty value means a normal post."
            repostVisibility:
              type: string
              enum: [PUBLIC, CONNECTIONS, ""]
              default: ""
              description: "Repost visibility. CONNECTIONS is personal-profile-only; company-page reposts must use PUBLIC."
    PlatformConnection:
      type: object
      properties:
        platformId:
          type: string
          description: "Unique ID in format platform-id (e.g., twitter-123456789)"
          example: "twitter-123456789"
        username:
          type: string
          example: "@yourhandle"
        displayName:
          type: string
          example: "Your Name"
        profileImageUrl:
          type: string
          format: uri
          example: "https://pbs.twimg.com/profile_images/..."
        profileUrl:
          type: string
          format: uri
          nullable: true
          description: "URL to the user's profile on the platform. Can be null if not available for the platform."
          example: "https://twitter.com/yourhandle"
        accessTokenExpiresAt:
          type: string
          format: date-time
          nullable: true
          example: "2026-05-15T10:30:00.000Z"
        tokenStatus:
          type: string
          enum: [valid, expiring_soon, expired, unknown]
          description: "Current status of the access token"
          example: "valid"
        tokenExpiresIn:
          type: string
          nullable: true
          description: "Human-readable time until token expiration"
          example: "29 days"
        lastSuccessfulPost:
          type: string
          format: date-time
          nullable: true
          description: "Timestamp of last successful post"
        lastError:
          $ref: "#/components/schemas/ConnectionLastError"

    ConnectionLastError:
      type: object
      nullable: true
      description: "Last connection error, or null"
      properties:
        message:
          type: string
        occurredAt:
          type: string
          format: date-time

    LinkedInUrnTranslation:
      type: object
      description: "Present only when LinkedIn translated an activity URN before processing the reaction"
      required: [from, to]
      properties:
        from:
          type: string
        to:
          type: string

    ManagedUser:
      type: object
      properties:
        _id:
          type: string
          description: "User ID"
          example: "507f1f77bcf86cd799439011"
        username:
          type: string
          description: "User email"
          example: "user@example.com"
        displayName:
          type: string
          example: "John Doe"
        role:
          type: string
          enum: [managed]
          example: "managed"
        dailyPostsLeft:
          type: integer
          example: 100
        connectionsPageUrl:
          type: string
          format: uri
          nullable: true
          description: "URL for user to connect their social accounts"
        connectionsPageTokenExpiresAt:
          type: string
          format: date-time
          nullable: true

    Webhook:
      type: object
      properties:
        _id:
          type: string
          example: "507f1f77bcf86cd799439011"
        name:
          type: string
          example: "My Webhook"
        url:
          type: string
          format: uri
          example: "https://example.com/webhook"
        events:
          type: array
          items:
            type: string
            enum: [post.scheduled, post.published, post.failed, post.demoted, token.expiring]
            description: "token.expiring is defined and subscribable but is not currently dispatched; do not depend on it"
          example: ["post.published", "post.failed"]
        isActive:
          type: boolean
          example: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    PostLog:
      type: object
      properties:
        _id:
          type: string
        postGroupId:
          type: string
        platform:
          type: string
        postId:
          type: string
        event:
          type: string
          enum: [scheduled, processing, publish_attempted, publish_succeeded, publish_failed, publish_retry, status_changed, media_finalize_succeeded, media_finalize_failed, linkedin_document_upload_succeeded, linkedin_document_upload_failed]
          description: "Stable PostLog event type"
        error:
          type: string
          description: "Optional; omitted when no error was stored"
        httpStatus:
          type: integer
          nullable: true
          description: "Optional; may be null or omitted when not applicable"
        retryCount:
          type: integer
          description: "Optional; omitted when no value was stored"
        metadata:
          type: object
          additionalProperties: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    Post:
      type: object
      properties:
        platform:
          type: string
          enum: [twitter, linkedin, instagram, threads, tiktok, youtube, facebook, bluesky, mastodon, telegram, pinterest]
          example: "twitter"
        platformId:
          type: string
          nullable: true
          description: "Platform connection ID. Always present; null if the post has no connection resolved."
          example: "123456789"
        content:
          type: string
          example: "Hello from Publora API!"
        status:
          type: string
          enum: [draft, scheduled, pending, processing, published, failed]
          example: "published"
        postedId:
          type: string
          nullable: true
          description: "Platform-specific post ID. Always present; null until the post is published."
          example: "1234567890123456789"
        permalink:
          type: string
          nullable: true
          description: |
            Public URL of the published post. Always present; null until the post
            is published, and null for platforms that expose no permalink.
          example: "https://x.com/publora/status/1234567890123456789"

    Warning:
      type: object
      description: |
        A non-fatal notice about a request that still succeeded. The `warnings`
        array is only present when at least one warning applies — the key is
        omitted, not empty, otherwise.

        Treat `code` as the contract: `message` is human-readable and may change
        at any time. Which fields accompany `code` depends on the code itself.
      properties:
        code:
          type: string
          enum: [SCHEDULED_TIME_COERCED, MEDIA_VALIDATION_PENDING]
          description: |
            - `SCHEDULED_TIME_COERCED` — the requested `scheduledTime` was in the
              past and was clamped to server time. Carries `requested` and
              `effective`.
            - `MEDIA_VALIDATION_PENDING` — /update-post only. Media validation had
              not finished, but the post was scheduled anyway; the media is
              re-checked before publishing. Carries `mediaFileId`, `mediaStatus`,
              `pendingCode`, and `attempts`. Accompanied by
              `mediaValidationStatus: "pending"` on the response.
          example: "SCHEDULED_TIME_COERCED"
        message:
          type: string
          description: "Human-readable explanation. Do not match on this string — match on code."
          example: "Requested scheduled time 2026-03-01T14:00:00.000Z was in the past and was changed to server time 2026-03-01T14:02:11.412Z."
        requested:
          type: string
          format: date-time
          description: "SCHEDULED_TIME_COERCED only. The scheduledTime you sent."
          example: "2026-03-01T14:00:00.000Z"
        effective:
          type: string
          format: date-time
          description: "SCHEDULED_TIME_COERCED only. The scheduledTime actually stored."
          example: "2026-03-01T14:02:11.412Z"
        mediaFileId:
          type: string
          nullable: true
          description: "MEDIA_VALIDATION_PENDING only. The media file still being validated."
          example: "507f1f77bcf86cd799439012"
        mediaStatus:
          type: string
          nullable: true
          description: "MEDIA_VALIDATION_PENDING only. Status of that media file when the response was sent."
          example: "uploading"
        pendingCode:
          type: string
          nullable: true
          description: "MEDIA_VALIDATION_PENDING only. The last transient probe result."
          example: "MEDIA_VALIDATION_PENDING"
        attempts:
          type: integer
          description: "MEDIA_VALIDATION_PENDING only. How many validation probes ran before responding."
          example: 3

    Error:
      type: object
      properties:
        error:
          type: string
          description: "Human-readable message. Do not match on this string — match on code where present."
          example: "Invalid API key"
        code:
          type: string
          description: |
            Stable machine-readable error code. Present on the newer error paths
            (scheduling, platformSettings validation, idempotency); older errors
            return `error` only. Always prefer this over the `error` text.
          example: "SCHEDULED_TIME_IN_PAST"
        field:
          type: string
          description: |
            `PLATFORM_SETTING_UNKNOWN` only. The exact dotted path of the rejected
            key, relative to the `platformSettings` object (no `platformSettings.`
            prefix).
          example: "youtube.thumbnail.typo"
        serverTime:
          type: string
          format: date-time
          description: |
            `SCHEDULED_TIME_IN_PAST` only. Current server time (UTC) when the
            request was rejected — compare against your clock to diagnose skew.
          example: "2026-03-01T14:02:11.412Z"

    MediaUrlRateLimitError:
      type: object
      required: [error, code, retryAfterSec]
      properties:
        error:
          type: string
          example: "Media URL ingestion rate limit reached (60 URLs/hour). Retry later or use the presigned get-upload-url flow."
        code:
          type: string
          enum: [MEDIA_URL_RATE_LIMITED]
        retryAfterSec:
          type: integer
          description: "Seconds until this fixed-window limit may be retried"
          example: 60

paths:
  /platform-connections:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    get:
      summary: List connected social accounts
      description: Returns all social media accounts connected to your Publora account.
      operationId: getPlatformConnections
      tags:
        - Connections
      parameters:
        - name: x-publora-user-id
          in: header
          required: false
          description: Managed user ID (workspace only)
          schema:
            type: string
      responses:
        "200":
          description: List of connected accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  connections:
                    type: array
                    items:
                      $ref: "#/components/schemas/PlatformConnection"
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Failed to fetch platform connections"

  /platform-limits:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    get:
      summary: Get live per-platform posting limits
      description: |
        Return Publora's per-platform limits (characters, images, videos,
        documents, GIFs, thumbnails, and requirements) as live JSON, sourced
        from the shared @publora/platform-limits package. These are the
        API-specific limits Publora validates against before scheduling — use
        them to drive client-side validation instead of hard-coding values.
        No parameters.
      operationId: getPlatformLimits
      tags:
        - Posts
      responses:
        "200":
          description: Per-platform limits
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  schemaVersion:
                    type: integer
                    example: 1
                  source:
                    type: string
                    example: "@publora/platform-limits"
                  packageVersion:
                    type: string
                    example: "1.0.0"
                  limitsLastUpdated:
                    type: string
                    example: "2026-03-11"
                  supportedPlatforms:
                    type: array
                    items:
                      type: string
                    example: ["twitter", "instagram", "threads", "tiktok", "linkedin", "youtube", "facebook", "mastodon", "bluesky", "telegram", "pinterest"]
                  platforms:
                    type: object
                    description: "Keyed by platform. Each value carries platform, displayName, characters, images, videos, requirements, plus documents, gifs, and thumbnails (null when the platform has no distinct spec)."
                    additionalProperties:
                      type: object
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /create-post:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Create and schedule a social media post
      description: |
        Create a post to be published across one or more social media platforms.
        Supports text, images, and video (1 video per post).
        The image count per post is platform-specific: Twitter/X, Bluesky, and
        Mastodon allow up to 4; Instagram, LinkedIn, Facebook, and Telegram
        allow up to 10; Threads allows up to 20. There is no separate
        "carousel" field — a
        carousel is formed implicitly by attaching multiple images to one
        postGroupId (Instagram: 2-10 images = carousel, 1 image = single photo),
        in upload order. See the Media Uploads guide for the full matrix.
        If scheduledTime is provided, the post will be published at that time.
        If omitted, the post is saved as a draft.
      operationId: createPost
      tags:
        - Posts
      parameters:
        - name: x-publora-user-id
          in: header
          required: false
          description: Managed user ID (workspace only)
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Opt-in idempotency. Omit the header for the previous behaviour —
            every call creates a new post.

            Send a unique value (a UUID is a good choice) per logical operation
            to make retries safe:

            - Same key + identical body, original request finished → the original
              status code and response body are replayed. No second post.
            - Same key + identical body, original request still in flight → `409`
              (`IDEMPOTENCY_IN_FLIGHT`). Retry shortly.
            - Same key + different body → `422` (`IDEMPOTENCY_KEY_CONFLICT`).
              Nothing is created.

            Keys are scoped to the acting user (the managed user when
            `x-publora-user-id` is set), so they never collide across accounts.
            Records expire 24 hours after they are created; reusing a key after
            that window is treated as a brand-new request.
          schema:
            type: string
          example: "6f1e2d3c-4b5a-4c7d-8e9f-0a1b2c3d4e5f"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platforms]
              properties:
                content:
                  type: string
                  description: "Normally required and non-empty. May be omitted or empty only when a targeted LinkedIn connection has repost intent via platformSettings.linkedin.repostEnabled=true or a non-empty repostParentUrn; repost validation still requires a valid parent/setting combination."
                  example: "Excited to share our new product launch! 🚀 #launch"
                platforms:
                  type: array
                  items:
                    type: string
                  description: "Array of platform connection IDs (format: platform-platformId)"
                  example: ["twitter-123456789", "linkedin-ABC123"]
                scheduledTime:
                  type: string
                  format: date-time
                  description: |
                    ISO 8601 UTC datetime for scheduling. Omit for draft.

                    If the time is in the past, it is clamped to the current server
                    time and a `SCHEDULED_TIME_COERCED` warning is returned — the
                    post is still created. Read the `scheduledTime` in the response
                    for the time actually stored.

                    Times less than 5 minutes in the past are always tolerated this
                    way. Strict rejection for a time 5 or more minutes in the past
                    is scheduled to begin on **2026-08-25**, unless production
                    configuration overrides that date. Before strict mode it is
                    clamped with a warning. Send a future time to avoid both.
                  example: "2027-03-01T14:00:00.000Z"
                platformSettings:
                  $ref: "#/components/schemas/PlatformSettingsInput"
                mediaUrls:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items:
                    type: string
                    format: uri
                  description: "Public https URLs downloaded server-side. Images: 25 MB each; videos: 150 MB each; aggregate: 300 MB. Ingestion is all-or-nothing."
      responses:
        "200":
          description: Post created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  postGroupId:
                    type: string
                    example: "507f1f77bcf86cd799439011"
                  scheduledTime:
                    type: string
                    format: date-time
                    nullable: true
                    description: |
                      The effective scheduled time that was actually stored. Always
                      present. `null` when the post was saved as a draft (no
                      scheduledTime sent). This may differ from the value you sent
                      — if so, a `SCHEDULED_TIME_COERCED` warning explains why.
                      Trust this field over your requested value.
                    example: "2026-03-01T14:00:00.000Z"
                  warnings:
                    type: array
                    description: |
                      Non-fatal notices about a request that still succeeded. Only
                      present when at least one warning applies.
                    items:
                      $ref: "#/components/schemas/Warning"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingContent:
                  value: { "error": "Content is required" }
                invalidTime:
                  value: { "error": "Invalid scheduled time format" }
                pastTime:
                  summary: "scheduledTime 5+ minutes in the past (strict mode)"
                  value:
                    {
                      "error": "Scheduled time is in the past. Server time is 2026-09-01T14:02:11.412Z UTC.",
                      "code": "SCHEDULED_TIME_IN_PAST",
                      "serverTime": "2026-09-01T14:02:11.412Z",
                    }
                unknownPlatformSetting:
                  summary: "Unrecognised platformSettings path — nothing was created"
                  value:
                    {
                      "error": "Unknown platformSettings path: youtube.thumbnail.typo",
                      "code": "PLATFORM_SETTING_UNKNOWN",
                      "field": "youtube.thumbnail.typo",
                    }
                idempotencyBodyTooComplex:
                  summary: "Idempotency-Key sent with an excessively nested body"
                  value:
                    {
                      "error": "Idempotency-Key request body is too deeply nested",
                      "code": "IDEMPOTENCY_BODY_TOO_COMPLEX",
                    }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Access entitlement or plan limit denied; inspect the returned code/error rather than matching an example string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            An earlier request with this `Idempotency-Key` is still in flight.
            Nothing was created by this call — retry shortly to receive the
            original response. Match on `code`; the `error` text varies.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                inFlight:
                  value:
                    {
                      "error": "A request with this idempotency key is still in flight",
                      "code": "IDEMPOTENCY_IN_FLIGHT",
                    }
                superseded:
                  value:
                    {
                      "error": "Idempotency claim was superseded by another request",
                      "code": "IDEMPOTENCY_IN_FLIGHT",
                    }
        "422":
          description: |
            This `Idempotency-Key` was already used with a different request body.
            Nothing was created — either replay the original body, or retry with a
            new key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Idempotency key was already used with a different request body"
                code: "IDEMPOTENCY_KEY_CONFLICT"
        "429":
          description: Media URL ingestion rate limit (60 URLs per fixed one-hour window)
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until retry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaUrlRateLimitError"
              example:
                error: "Media URL ingestion rate limit reached (60 URLs/hour). Retry later or use the presigned get-upload-url flow."
                code: "MEDIA_URL_RATE_LIMITED"
                retryAfterSec: 60
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /get-post/{postGroupId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    get:
      summary: Get post details and status
      description: Retrieve the details, platform statuses, and posted IDs for a post group.
      operationId: getPost
      tags:
        - Posts
      parameters:
        - name: postGroupId
          in: path
          required: true
          schema:
            type: string
          example: "507f1f77bcf86cd799439011"
        - name: x-publora-user-id
          in: header
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Post details
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  postGroupId:
                    type: string
                  status:
                    type: string
                    enum: [draft, scheduled, published, failed, partially_published]
                    description: "Post-group status. Always present."
                  scheduledTime:
                    type: string
                    format: date-time
                    nullable: true
                    description: |
                      The effective scheduled time stored for this post group.
                      Always present; `null` for drafts and for groups that were
                      never given a time.
                    example: "2026-03-01T14:00:00.000Z"
                  platformSettings:
                    type: object
                    description: |
                      Per-platform settings currently stored on this post group,
                      keyed by platform. Always present; `{}` when none are set.
                      Mirrors the platformSettings accepted by /create-post and
                      /update-post.
                    example:
                      { "youtube": { "privacy": "public", "madeForKids": false } }
                  platforms:
                    type: array
                    items:
                      type: string
                    description: |
                      Platform connection IDs targeted by this post group (format:
                      platform-platformId). Always present; `[]` when none.
                    example: ["twitter-123456789", "linkedin-ABC123"]
                  posts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Post"
                  media:
                    type: array
                    description: "Attached media inventory in post-group order. Always present; empty when none is attached."
                    items:
                      type: object
                      properties:
                        mediaId: { type: string }
                        sourceFileName: { type: string, nullable: true }
                        fileName: { type: string, nullable: true }
                        type: { type: string, nullable: true }
                        mimeType: { type: string, nullable: true }
                        status: { type: string }
                        failureReason: { type: string, nullable: true }
                        url: { type: string, nullable: true }
        "404":
          description: Post not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /update-post/{postGroupId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    put:
      summary: Update post timing, status, or platform settings
      description: |
        Modify the scheduled time, status, platformSettings, or mediaUrls of an existing
        draft or scheduled post.
        Only post groups whose status is draft or scheduled can be updated. `pending`
        and `processing` belong to separate processing/per-platform state, not
        to the post-group status enum.
        At least one of status, scheduledTime, platformSettings, or mediaUrls must be provided.
        platformSettings are merged per-platform with existing settings —
        omitted fields are preserved.
        Updating a post group also updates all associated platform-specific posts.
      operationId: updatePost
      tags:
        - Posts
      parameters:
        - name: postGroupId
          in: path
          required: true
          schema:
            type: string
        - name: x-publora-user-id
          in: header
          required: false
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Opt-in idempotency. Omit the header for the previous behaviour —
            every call applies the update again.

            Send a unique value (a UUID is a good choice) per logical operation
            to make retries safe:

            - Same key + identical body, original request finished → the original
              status code and response body are replayed. The update is not
              re-applied.
            - Same key + identical body, original request still in flight → `409`
              (`IDEMPOTENCY_IN_FLIGHT`). Retry shortly.
            - Same key + different body → `422` (`IDEMPOTENCY_KEY_CONFLICT`).
              Nothing is changed.

            The key is bound to this `postGroupId` as well as the body, so the
            same key reused against a different post counts as a different
            request. Keys are scoped to the acting user (the managed user when
            `x-publora-user-id` is set), so they never collide across accounts.
            Records expire 24 hours after they are created; reusing a key after
            that window is treated as a brand-new request.
          schema:
            type: string
          example: "6f1e2d3c-4b5a-4c7d-8e9f-0a1b2c3d4e5f"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "At least one of status, scheduledTime, platformSettings, or mediaUrls must be provided"
              properties:
                status:
                  type: string
                  enum: [draft, scheduled]
                  description: "New status"
                scheduledTime:
                  type: string
                  format: date-time
                  description: |
                    New ISO 8601 UTC scheduled time. Send a future time.

                    If the time is in the past, it is clamped to the current server
                    time and a `SCHEDULED_TIME_COERCED` warning is returned — the
                    update still succeeds. Read the `scheduledTime` in the response
                    for the time actually stored.

                    Times less than 5 minutes in the past are always tolerated this
                    way. Strict rejection for a time 5 or more minutes in the past
                    is scheduled to begin on **2026-08-25**, unless production
                    configuration overrides that date. Before strict mode it is
                    clamped with a warning.

                    The same check applies to the post's existing scheduledTime when
                    you move a post to `status: "scheduled"` without sending a new
                    time — a long-stale draft can therefore be rejected.
                platformSettings:
                  $ref: "#/components/schemas/PlatformSettingsInput"
                mediaUrls:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items:
                    type: string
                    format: uri
                  description: "Public https URLs downloaded server-side and appended to existing media. Images: 25 MB each; videos: 150 MB each; aggregate: 300 MB. Ingestion is all-or-nothing."
      responses:
        "200":
          description: Post updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: "Post updated successfully"
                  scheduledTime:
                    type: string
                    format: date-time
                    nullable: true
                    description: |
                      The effective scheduled time that was actually stored. Always
                      present. `null` when the post has no scheduled time (e.g. it
                      was moved back to draft). This may differ from the value you
                      sent — if so, a `SCHEDULED_TIME_COERCED` warning explains
                      why. Trust this field over your requested value.
                    example: "2026-03-01T14:00:00.000Z"
                  mediaValidationStatus:
                    type: string
                    enum: [pending]
                    description: |
                      Only present when the post was scheduled while media
                      validation was still unfinished. The media is re-checked
                      before publishing. Accompanied by a
                      `MEDIA_VALIDATION_PENDING` entry in `warnings`.
                    example: "pending"
                  warnings:
                    type: array
                    description: |
                      Non-fatal notices about a request that still succeeded. Only
                      present when at least one warning applies.
                    items:
                      $ref: "#/components/schemas/Warning"
                  postGroup:
                    type: object
                    properties:
                      _id:
                        type: string
                      status:
                        type: string
                      scheduledTime:
                        type: string
                        format: date-time
                        description: |
                          Only included if the post has a scheduled time. Duplicates
                          the top-level `scheduledTime`, which is always present and
                          is the preferred field to read.
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "At least one of status, scheduledTime, platformSettings, or mediaUrls must be provided" }
                invalidStatus:
                  value: { "error": "Status must be either 'draft' or 'scheduled'" }
                invalidTimeFormat:
                  value: { "error": "Invalid scheduled time format" }
                pastTime:
                  summary: "scheduledTime 5+ minutes in the past (strict mode)"
                  value:
                    {
                      "error": "Scheduled time is in the past. Server time is 2026-09-01T14:02:11.412Z UTC.",
                      "code": "SCHEDULED_TIME_IN_PAST",
                      "serverTime": "2026-09-01T14:02:11.412Z",
                    }
                unknownPlatformSetting:
                  summary: "Unrecognised platformSettings path — nothing was changed"
                  value:
                    {
                      "error": "Unknown platformSettings path: youtube.thumbnail.typo",
                      "code": "PLATFORM_SETTING_UNKNOWN",
                      "field": "youtube.thumbnail.typo",
                    }
                idempotencyBodyTooComplex:
                  summary: "Idempotency-Key sent with an excessively nested body"
                  value:
                    {
                      "error": "Idempotency-Key request body is too deeply nested",
                      "code": "IDEMPOTENCY_BODY_TOO_COMPLEX",
                    }
                cannotUpdate:
                  value: { "error": "Cannot update post: post is currently in published status" }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            An earlier request with this `Idempotency-Key` is still in flight.
            Nothing was changed by this call — retry shortly to receive the
            original response. Match on `code`; the `error` text varies.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                inFlight:
                  value:
                    {
                      "error": "A request with this idempotency key is still in flight",
                      "code": "IDEMPOTENCY_IN_FLIGHT",
                    }
                superseded:
                  value:
                    {
                      "error": "Idempotency claim was superseded by another request",
                      "code": "IDEMPOTENCY_IN_FLIGHT",
                    }
        "422":
          description: |
            This `Idempotency-Key` was already used with a different request body
            (or against a different postGroupId). Nothing was changed — either
            replay the original body, or retry with a new key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Idempotency key was already used with a different request body"
                code: "IDEMPOTENCY_KEY_CONFLICT"
        "404":
          description: Post not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Post group not found"
        "429":
          description: Media URL ingestion rate limit reached (60 URLs per fixed one-hour window)
          headers:
            Retry-After:
              description: Seconds to wait before retrying
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaUrlRateLimitError"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Failed to update post"

  /delete-post/{postGroupId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    delete:
      summary: Delete a scheduled post
      description: |
        Delete a post group and all associated data. This operation removes:
        - The post group record
        - All platform-specific posts (Twitter, LinkedIn, Instagram, etc.)
        - All media file records from the database
        - All media files from S3 storage

        The database deletion is atomic (uses MongoDB transaction). S3 deletion
        failures are logged but do not fail the request.
      operationId: deletePost
      tags:
        - Posts
      parameters:
        - name: postGroupId
          in: path
          required: true
          schema:
            type: string
        - name: x-publora-user-id
          in: header
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Post deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        "404":
          description: Post group not found (ID doesn't exist or belongs to another user)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Post group not found"
        "500":
          description: Server error during deletion (transaction rolled back)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Failed to delete post group"

  /media/{mediaId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    delete:
      summary: Delete an uploaded media file
      description: |
        Delete a media record and its stored object. If the media belonged to a
        scheduled post, the post is demoted to draft and must be scheduled again.
        Media cannot be removed while its post is publishing or after the group
        has reached published or failed status.
      operationId: deleteMedia
      tags: [Media]
      parameters:
        - name: mediaId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Media deleted
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    example: true
                  postGroupDemoted:
                    type: boolean
                  message:
                    type: string
                  groupAlreadyMissing:
                    type: boolean
                  mediaWasOrphan:
                    type: boolean
                  thumbnailCleared:
                    type: boolean
                  coverCleared:
                    type: boolean
        "400":
          description: Invalid media ID, or media belongs to a published or failed post
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Media file not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Post is publishing or changed state concurrently; retry later
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Failed to delete media
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /get-upload-url:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Get pre-signed URL for media upload
      description: |
        Get a pre-signed S3 URL to upload images, videos, or PDF documents.
        Upload the file directly to S3 via HTTP PUT, then create a post.
        Upload URLs expire in 1 hour. The image count per post is
        platform-specific (up to 4 for Twitter/X, Bluesky, Mastodon; up to 10
        for Instagram, LinkedIn, Facebook, Telegram; up to 20 for Threads), or 1 video per
        post. Attach multiple images to the same postGroupId to form a carousel;
        these post-media count rules do not apply to PDF document assets.
      operationId: getUploadUrl
      tags:
        - Media
      parameters:
        - name: x-publora-user-id
          in: header
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileName, contentType, postGroupId]
              properties:
                fileName:
                  type: string
                  example: "photo.jpg"
                contentType:
                  type: string
                  description: "MIME type. It must match an image/*, video/*, or application/pdf upload."
                  example: "image/jpeg"
                type:
                  type: string
                  enum: [image, video, document]
                  description: "Optional media-type hint; inferred from contentType when omitted"
                  example: "image"
                postGroupId:
                  type: string
                  description: "Post group to attach media to"
                  example: "507f1f77bcf86cd799439011"
      responses:
        "200":
          description: Upload URL generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  uploadUrl:
                    type: string
                    format: uri
                    description: "Pre-signed S3 URL. PUT your file here."
                  fileUrl:
                    type: string
                    format: uri
                    description: "Public URL after upload"
                  mediaId:
                    type: string
                    description: "Media record ID"
        "400":
          description: Missing required fields
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "fileName, contentType, and postGroupId are required" }
                invalidPostGroupId:
                  value: { "error": "Invalid postGroupId" }
                unsupportedContentType:
                  value: { "error": "Only video, image, and PDF files are allowed" }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                serverError:
                  value: { "error": "Server error" }

  /complete-media/{mediaFileId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Probe and finalize an uploaded media file
      description: |
        Optionally run the server-side media probe immediately after uploading
        bytes to the pre-signed URL. This call is not required: scheduling also
        probes media that is still uploading.
      operationId: completeMedia
      tags: [Media]
      parameters:
        - name: mediaFileId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Media probe completed
          content:
            application/json:
              schema:
                type: object
                required: [success, mediaFile]
                properties:
                  success:
                    type: boolean
                    example: true
                  mediaFile:
                    type: object
                    required: [_id]
                    properties:
                      _id:
                        type: string
                      type:
                        type: string
                        nullable: true
                      mimeType:
                        type: string
                        nullable: true
                      fileName:
                        type: string
                        nullable: true
                      url:
                        type: string
                        format: uri
                        nullable: true
                      status:
                        type: string
                        nullable: true
                      metadata:
                        type: object
                        nullable: true
                        additionalProperties: true
        "400":
          description: Invalid media ID or uploaded media failed validation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Media file not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Media was deleted or failed concurrently while being probed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Failed to complete media upload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Media probe service or uploaded object is temporarily unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /upload-instagram-cover:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Upload a custom Instagram Reel cover image
      description: |
        Upload a cover image for an Instagram Reel. The image is stored as a
        JPEG (PNG/WebP are transcoded server-side; transparency is flattened
        onto white) and a public URL is returned. Set that URL as
        platformSettings.instagram.coverUrl via update-post to use it as the
        Reel cover. The target post group must be an editable (draft or
        scheduled) post you own and not currently publishing.
        Covers apply to Reels only and are ignored for Stories and images.
      operationId: uploadInstagramCover
      tags:
        - Media
      parameters:
        - name: x-publora-user-id
          in: header
          required: false
          description: Managed user ID (workspace only)
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [cover, postGroupId]
              properties:
                cover:
                  type: string
                  format: binary
                  description: "Cover image (JPEG, PNG, or WebP; 8 MB max). PNG/WebP are converted to JPEG."
                postGroupId:
                  type: string
                  description: "Editable post group (draft or scheduled) to attach the cover to"
                  example: "507f1f77bcf86cd799439011"
      responses:
        "200":
          description: Cover uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  cover:
                    type: object
                    properties:
                      mediaId:
                        type: string
                        description: "Cover media record ID"
                        example: "665f8a1b2c3d4e5f6a7b8c9d"
                      url:
                        type: string
                        format: uri
                        description: "Public JPEG URL — set as platformSettings.instagram.coverUrl via update-post"
                        example: "https://media.publora.com/images/665f...-reel-cover.jpg"
                  message:
                    type: string
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingPostGroupId:
                  value: { "error": "postGroupId is required" }
                invalidPostGroupId:
                  value: { "error": "Invalid postGroupId" }
                missingFile:
                  value: { "error": "cover file is required" }
                badType:
                  value: { "error": "Instagram covers must be JPEG, PNG, or WebP images" }
                tooLarge:
                  value: { "error": "Instagram covers must be 8 MB or smaller" }
                tooLargeAfterTranscode:
                  value:
                    {
                      "error": "Instagram cover exceeds 8 MB after JPEG conversion; use a smaller image",
                    }
                nonEditable:
                  value: { "error": "Cannot upload Instagram cover for a non-editable post" }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Post group not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Post group not found"
        "409":
          description: Post group is currently publishing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Post group is currently being published; cannot upload Instagram cover. Retry once publishing completes."
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Failed to upload Instagram cover"

  /upload-youtube-thumbnail:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Upload a custom YouTube video thumbnail
      description: |
        Upload a custom thumbnail for a YouTube video post. The image is stored
        as Publora-tracked media and a mediaId + url are returned; attach them
        via platformSettings.youtube.thumbnail on update-post. A thumbnail
        cannot be set on create-post — the upload requires an existing
        postGroupId (draft or scheduled) that is not currently publishing.
        JPEG or PNG, 2 MB max, minimum 640x360 px (1280x720 recommended).
      operationId: uploadYouTubeThumbnail
      tags:
        - Media
      parameters:
        - name: x-publora-user-id
          in: header
          required: false
          description: Managed user ID (workspace only)
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [thumbnail, postGroupId]
              properties:
                thumbnail:
                  type: string
                  format: binary
                  description: "Thumbnail image (JPEG or PNG; 2 MB max; min 640x360 px, 1280x720 recommended)."
                postGroupId:
                  type: string
                  description: "Editable post group (draft or scheduled) to attach the thumbnail to"
                  example: "507f1f77bcf86cd799439011"
      responses:
        "200":
          description: Thumbnail uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  thumbnail:
                    type: object
                    properties:
                      mediaId:
                        type: string
                        description: "Thumbnail media record ID — set as platformSettings.youtube.thumbnail.mediaId via update-post"
                        example: "665f8a1b2c3d4e5f6a7b8c9d"
                      url:
                        type: string
                        format: uri
                        description: "Public thumbnail URL — set as platformSettings.youtube.thumbnail.url via update-post"
                        example: "https://media.publora.com/images/665f...-thumb.jpg"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingPostGroupId:
                  value: { "error": "postGroupId is required" }
                invalidPostGroupId:
                  value: { "error": "Invalid postGroupId" }
                missingFile:
                  value: { "error": "thumbnail file is required" }
                badType:
                  value: { "error": "YouTube thumbnails must be JPEG or PNG images" }
                tooLarge:
                  value: { "error": "YouTube thumbnails must be 2 MB or smaller" }
                tooSmall:
                  value: { "error": "YouTube thumbnails must be at least 640x360 pixels" }
                nonEditable:
                  value: { "error": "Cannot upload YouTube thumbnail for a non-editable post" }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Post group not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Post group not found"
        "409":
          description: Post group is currently publishing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Post group is currently being published; cannot upload YouTube thumbnail. Retry once publishing completes."
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Failed to upload YouTube thumbnail"

  /linkedin-post-statistics:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Get LinkedIn post analytics
      description: |
        Retrieve analytics for a LinkedIn post: impressions, reach, reactions, comments, reshares.
        Results are cached for 2 hours. Use queryTypes: "ALL" to get all 5 metrics at once.
      operationId: getLinkedInPostStatistics
      tags:
        - LinkedIn Analytics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [postedId, platformId]
              properties:
                postedId:
                  type: string
                  description: "LinkedIn URN or share ID"
                  example: "urn:li:share:7123456789012345678"
                platformId:
                  type: string
                  description: "LinkedIn connection ID"
                  example: "linkedin-Tz9W5i6ZYG"
                queryType:
                  type: string
                  enum: [IMPRESSION, MEMBERS_REACHED, RESHARE, REACTION, COMMENT]
                  description: "Single metric to fetch"
                queryTypes:
                  oneOf:
                    - type: array
                      items:
                        type: string
                        enum: [IMPRESSION, MEMBERS_REACHED, RESHARE, REACTION, COMMENT]
                    - type: string
                      enum: ["ALL"]
                  description: "Multiple metrics or ALL"
                  example: "ALL"
      responses:
        "200":
          description: Statistics retrieved
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: Single metric response
                    properties:
                      success:
                        type: boolean
                      count:
                        type: integer
                        example: 1542
                      cached:
                        type: boolean
                  - type: object
                    description: Multiple metrics response
                    properties:
                      success:
                        type: boolean
                      metrics:
                        type: object
                        properties:
                          IMPRESSION:
                            type: integer
                          MEMBERS_REACHED:
                            type: integer
                          RESHARE:
                            type: integer
                          REACTION:
                            type: integer
                          COMMENT:
                            type: integer
                      cached:
                        type: boolean
        "400":
          description: Bad request - missing or invalid parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value:
                    error: "postedId and platformId are required"
                invalidQueryType:
                  value:
                    error: "Invalid queryType. Must be one of: IMPRESSION, MEMBERS_REACHED, RESHARE, REACTION, COMMENT"
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "LinkedIn connection not found"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Failed to fetch LinkedIn post statistics"

  /linkedin-account-statistics:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Get aggregated LinkedIn account analytics
      description: Get total metrics across all published LinkedIn posts for an account.
      operationId: getLinkedInAccountStatistics
      tags:
        - LinkedIn Analytics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platformId]
              properties:
                platformId:
                  type: string
                  example: "linkedin-Tz9W5i6ZYG"
                queryType:
                  type: string
                  enum: [IMPRESSION, MEMBERS_REACHED, RESHARE, REACTION, COMMENT]
                  description: "Single metric to fetch"
                queryTypes:
                  oneOf:
                    - type: array
                      items:
                        type: string
                        enum: [IMPRESSION, MEMBERS_REACHED, RESHARE, REACTION, COMMENT]
                    - type: string
                      enum: ["ALL"]
                  description: "Multiple metrics or ALL"
                  example: "ALL"
                aggregation:
                  type: string
                  enum: [TOTAL, DAILY]
                  default: TOTAL
                  description: "Aggregation type: TOTAL (default) or DAILY"
                dateRange:
                  type: object
                  description: "Optional date range filter"
                  properties:
                    start:
                      type: object
                      properties:
                        year:
                          type: integer
                        month:
                          type: integer
                        day:
                          type: integer
                    end:
                      type: object
                      properties:
                        year:
                          type: integer
                        month:
                          type: integer
                        day:
                          type: integer
      responses:
        "200":
          description: Account statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  metrics:
                    type: object
                    description: "Flexible object with dynamic metric keys (e.g., IMPRESSION, REACTION, etc.)"
                    additionalProperties:
                      type: integer
                  aggregation:
                    type: string
                    enum: [TOTAL, DAILY]
                  cached:
                    type: boolean
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingPlatformId:
                  value: { "error": "platformId is required" }
                invalidQueryType:
                  value: { "error": "Invalid queryType" }
                invalidAggregation:
                  value: { "error": "Invalid aggregation" }
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /linkedin-profile-summary:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Get LinkedIn profile summary
      description: |
        Retrieve a summary of your LinkedIn profile analytics including follower counts
        and aggregated post engagement metrics. Optionally filter by date range to track
        follower growth over a specific period.
      operationId: getLinkedInProfileSummary
      tags:
        - LinkedIn Analytics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platformId]
              properties:
                platformId:
                  type: string
                  description: "LinkedIn connection ID (format: linkedin-{id})"
                  example: "linkedin-Tz9W5i6ZYG"
                dateRange:
                  type: object
                  description: "Optional date range for filtering metrics and calculating follower growth"
                  properties:
                    start:
                      type: object
                      properties:
                        year:
                          type: integer
                          example: 2026
                        month:
                          type: integer
                          example: 1
                        day:
                          type: integer
                          example: 1
                    end:
                      type: object
                      properties:
                        year:
                          type: integer
                          example: 2026
                        month:
                          type: integer
                          example: 1
                        day:
                          type: integer
                          example: 31
      responses:
        "200":
          description: Profile summary retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  partialData:
                    type: boolean
                    description: "True if some data failed to fetch but partial results are available"
                  profile:
                    type: object
                    properties:
                      followers:
                        type: object
                        properties:
                          total:
                            type: integer
                            description: "Total follower count"
                            example: 5000
                          periodGrowth:
                            type: integer
                            description: "Follower growth during date range (only present when dateRange is provided)"
                            example: 150
                      posts:
                        type: object
                        properties:
                          totalImpressions:
                            type: integer
                            example: 25000
                          totalReactions:
                            type: integer
                            example: 500
                          totalComments:
                            type: integer
                            example: 75
                          totalReshares:
                            type: integer
                            example: 30
                          totalMembersReached:
                            type: integer
                            example: 12000
                  errors:
                    type: array
                    items:
                      type: object
                      required: [metric, error]
                      properties:
                        metric:
                          type: string
                        error:
                          type: string
                    description: "List of errors when partialData is true"
                    example: [{ "metric": "followers", "error": "Failed to fetch followers" }]
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingPlatformId:
                  value: { "error": "platformId is required" }
                invalidDateRange:
                  value: { "error": "dateRange must be an object" }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "LinkedIn connection not found"
        "500":
          description: Unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: All requests to LinkedIn failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Failed to fetch LinkedIn profile data"

  /linkedin-followers:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Get LinkedIn follower statistics
      description: |
        Retrieve follower statistics for your LinkedIn account.
        Use period="lifetime" (default) for total follower count, or period="daily" with a dateRange for daily growth data.
        Results are cached for improved performance.
      operationId: getLinkedInFollowers
      tags:
        - LinkedIn Analytics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platformId]
              properties:
                platformId:
                  type: string
                  description: "LinkedIn connection ID"
                  example: "linkedin-Tz9W5i6ZYG"
                period:
                  type: string
                  enum: [lifetime, daily]
                  default: lifetime
                  description: "Type of statistics to retrieve"
                  example: "lifetime"
                dateRange:
                  type: object
                  description: "Required when period is 'daily'"
                  properties:
                    start:
                      type: object
                      properties:
                        year:
                          type: integer
                          example: 2024
                        month:
                          type: integer
                          example: 1
                        day:
                          type: integer
                          example: 1
                    end:
                      type: object
                      properties:
                        year:
                          type: integer
                          example: 2024
                        month:
                          type: integer
                          example: 1
                        day:
                          type: integer
                          example: 31
      responses:
        "200":
          description: Follower statistics retrieved
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: Lifetime response
                    properties:
                      success:
                        type: boolean
                        example: true
                      followersCount:
                        type: integer
                        example: 1234
                      cached:
                        type: boolean
                  - type: object
                    description: Daily response
                    properties:
                      success:
                        type: boolean
                        example: true
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            date:
                              type: string
                              example: "2024-01-15"
                            count:
                              type: integer
                              example: 10
                      totalGrowth:
                        type: integer
                        example: 25
                      cached:
                        type: boolean
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingPlatformId:
                  value: { "error": "platformId is required" }
                invalidPeriod:
                  value: { "error": "Invalid period" }
                missingDateRange:
                  value: { "error": "dateRange is required for daily period" }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Failed to fetch statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                fetchFailed:
                  value: { "error": "Failed to fetch LinkedIn followers statistics" }

  /linkedin-reactions:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Add a reaction to a LinkedIn post
      description: React to a LinkedIn post with LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, or ENTERTAINMENT.
      operationId: addLinkedInReaction
      tags:
        - LinkedIn Analytics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [postedId, reactionType, platformId]
              properties:
                postedId:
                  type: string
                  description: "LinkedIn URN (urn:li:share:*, urn:li:ugcPost:*, urn:li:activity:*)"
                  example: "urn:li:share:7123456789012345678"
                reactionType:
                  type: string
                  enum: [LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT]
                  example: "LIKE"
                platformId:
                  type: string
                  example: "linkedin-Tz9W5i6ZYG"
      responses:
        "201":
          description: Reaction added
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  reaction:
                    type: object
                    description: "Reaction details"
                    properties:
                      reactionType:
                        type: string
                        enum: [LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT]
                      actor:
                        type: string
                        description: "LinkedIn member URN"
                      object:
                        type: string
                        description: "LinkedIn post URN"
                  urnTranslated:
                    $ref: "#/components/schemas/LinkedInUrnTranslation"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "postedId, reactionType, and platformId are required" }
                invalidReactionType:
                  value: { "error": "reactionType must be one of: LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT" }
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The requested reaction already exists
          content:
            application/json:
              schema:
                type: object
                required: [error, message, requestedReactionType]
                properties:
                  error:
                    type: string
                    enum: [REACTION_ALREADY_EXISTS]
                  message:
                    type: string
                  requestedReactionType:
                    type: string
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      summary: Remove a reaction from a LinkedIn post
      description: |
        Remove your reaction from a LinkedIn post.
        Parameters can be provided either as query parameters OR in the request body.
      operationId: removeLinkedInReaction
      tags:
        - LinkedIn Analytics
      parameters:
        - name: postedId
          in: query
          description: "LinkedIn URN (can also be provided in request body)"
          schema:
            type: string
          example: "urn:li:share:7123456789012345678"
        - name: platformId
          in: query
          description: "LinkedIn connection ID (can also be provided in request body)"
          schema:
            type: string
          example: "linkedin-Tz9W5i6ZYG"
      requestBody:
        required: false
        description: "Alternative to query parameters - provide postedId and platformId in body"
        content:
          application/json:
            schema:
              type: object
              properties:
                postedId:
                  type: string
                  description: "LinkedIn URN (urn:li:share:*, urn:li:ugcPost:*, urn:li:activity:*)"
                  example: "urn:li:share:7123456789012345678"
                platformId:
                  type: string
                  example: "linkedin-Tz9W5i6ZYG"
      responses:
        "200":
          description: Reaction removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  reaction:
                    type: object
                    description: "Details of the removed reaction"
                    properties:
                      reactionType:
                        type: string
                      actor:
                        type: string
                      object:
                        type: string
                  urnTranslated:
                    $ref: "#/components/schemas/LinkedInUrnTranslation"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "postedId and platformId are required" }
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /linkedin-reshare:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Reshare an existing LinkedIn post
      description: Reshare (repost) an existing LinkedIn post to your own feed with optional commentary. Works for personal and company-page connections (authored as the member or the organization automatically).
      operationId: createLinkedInReshare
      tags:
        - LinkedIn Analytics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platformId, parent]
              properties:
                platformId:
                  type: string
                  description: "LinkedIn connection ID"
                  example: "linkedin-Tz9W5i6ZYG"
                parent:
                  type: string
                  description: "URN of the post to reshare (urn:li:share:* or urn:li:ugcPost:*)"
                  example: "urn:li:share:7123456789012345678"
                commentary:
                  type: string
                  description: "Optional text added above the reshare (max 3000 characters)"
                  example: "Great read — sharing with my network!"
                visibility:
                  type: string
                  enum: [PUBLIC, CONNECTIONS]
                  default: PUBLIC
                  description: "Optional visibility (case-insensitive). Defaults to PUBLIC. CONNECTIONS is personal-profile-only; organization/company-page reshares must use PUBLIC."
                  example: "PUBLIC"
      responses:
        "201":
          description: Reshare created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  reshare:
                    type: object
                    description: "LinkedIn API response for the created reshare"
                    properties:
                      id:
                        type: string
                        description: "URN of the new reshare post"
                        example: "urn:li:share:7123456789012345678"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "platformId and parent are required" }
                invalidParent:
                  value: { "error": "parent must be a valid LinkedIn post URN (urn:li:share:<id> or urn:li:ugcPost:<id>)" }
                commentaryTooLong:
                  value: { "error": "commentary cannot exceed 3000 characters" }
                invalidVisibility:
                  value: { "error": "visibility must be one of: PUBLIC, CONNECTIONS" }
                organizationConnectionsVisibility:
                  value: { "error": "LinkedIn organization reposts cannot use CONNECTIONS visibility; choose PUBLIC" }
                missingOrganizationId:
                  value: { "error": "LinkedIn company connection is missing organizationId. Please reconnect the LinkedIn page." }
        "401":
          description: Invalid API key or expired LinkedIn token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                tokenExpired:
                  value: { "error": "LINKEDIN_TOKEN_EXPIRED" }
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /list-posts:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    get:
      summary: List posts with pagination and filters
      description: |
        Retrieve a paginated list of all your scheduled, draft, published, and failed posts.

        **Pagination behavior:** The `page` and `limit` parameters are silently clamped to valid ranges
        rather than returning errors. `page` is clamped to a minimum of 1, and `limit` is clamped to 1-100.

        **Platform filtering:** The `platform` filter uses case-insensitive prefix matching.
      operationId: listPosts
      tags:
        - Posts
      parameters:
        - name: x-publora-user-id
          in: header
          required: false
          description: Managed user ID (workspace only)
          schema:
            type: string
        - name: page
          in: query
          required: false
          description: "Page number (1-indexed). Values < 1 are silently clamped to 1."
          schema:
            type: integer
            default: 1
            minimum: 1
        - name: limit
          in: query
          required: false
          description: "Items per page. Values are silently clamped to range 1-100."
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: status
          in: query
          required: false
          description: Filter by post status
          schema:
            type: string
            enum: [draft, scheduled, published, failed, partially_published]
        - name: platform
          in: query
          required: false
          description: "Filter by platform (case-insensitive prefix matching)"
          schema:
            type: string
            enum: [twitter, linkedin, instagram, threads, tiktok, youtube, facebook, bluesky, mastodon, telegram, pinterest]
        - name: sortBy
          in: query
          required: false
          description: Field to sort by
          schema:
            type: string
            enum: [createdAt, updatedAt, scheduledTime]
            default: createdAt
        - name: sortOrder
          in: query
          required: false
          description: Sort order
          schema:
            type: string
            enum: [asc, desc]
            default: desc
        - name: fromDate
          in: query
          required: false
          description: Filter posts scheduled after this ISO 8601 date
          schema:
            type: string
            format: date-time
        - name: toDate
          in: query
          required: false
          description: Filter posts scheduled before this ISO 8601 date
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Paginated list of posts
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  posts:
                    type: array
                    items:
                      type: object
                      properties:
                        postGroupId:
                          type: string
                          example: "507f1f77bcf86cd799439011"
                        content:
                          type: string
                          example: "Excited to share our new product launch!"
                        status:
                          type: string
                          enum: [draft, scheduled, published, failed, partially_published]
                        scheduledTime:
                          type: string
                          format: date-time
                        createdAt:
                          type: string
                          format: date-time
                        updatedAt:
                          type: string
                          format: date-time
                        platforms:
                          type: array
                          items:
                            type: object
                            properties:
                              platformId:
                                type: string
                              platform:
                                type: string
                              status:
                                type: string
                        mediaUrls:
                          type: array
                          items:
                            type: string
                            format: uri
                  pagination:
                    type: object
                    properties:
                      page:
                        type: integer
                        example: 1
                      limit:
                        type: integer
                        example: 20
                      totalItems:
                        type: integer
                        example: 47
                      totalPages:
                        type: integer
                        example: 3
                      hasNextPage:
                        type: boolean
                        example: true
                      hasPrevPage:
                        type: boolean
                        example: false
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalidStatus:
                  value: { "error": "Invalid status. Must be one of: draft, scheduled, published, failed, partially_published" }
                invalidSortBy:
                  value: { "error": "Invalid sortBy. Must be one of: createdAt, updatedAt, scheduledTime" }
                invalidFromDate:
                  value: { "error": "Invalid fromDate format" }
                invalidToDate:
                  value: { "error": "Invalid toDate format" }
        "401":
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /test-connection/{platformId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Test platform connection health
      description: |
        Validate that a platform connection is working correctly.
        Returns token status, permissions, and last error if any.
      operationId: testConnection
      tags:
        - Connections
      parameters:
        - name: platformId
          in: path
          required: true
          description: "Platform connection ID (format: platform-id)"
          schema:
            type: string
          example: "linkedin-Tz9W5i6ZYG"
      responses:
        "200":
          description: Connection test result
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform:
                    type: string
                    example: "linkedin"
                  platformId:
                    type: string
                    example: "linkedin-Tz9W5i6ZYG"
                  username:
                    type: string
                    example: "John Doe"
                  status:
                    type: string
                    enum: [ok, error]
                    example: "ok"
                  message:
                    type: string
                    example: "Connection is valid"
                  permissions:
                    type: array
                    items:
                      type: string
                    example: ["w_member_social", "r_basicprofile"]
                  tokenExpiresIn:
                    type: string
                    nullable: true
                    example: "29 days"
                  lastSuccessfulPost:
                    type: string
                    format: date-time
                    nullable: true
                  lastError:
                    $ref: "#/components/schemas/ConnectionLastError"
        "400":
          description: Invalid platformId format
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /post-logs/{postGroupId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    get:
      summary: Get post publishing logs
      description: |
        Retrieve detailed logs for a post group showing the publishing process,
        including scheduling, processing, success, and failure events.
      operationId: getPostLogs
      tags:
        - Logs
      parameters:
        - name: postGroupId
          in: path
          required: true
          schema:
            type: string
          example: "507f1f77bcf86cd799439011"
      responses:
        "200":
          description: Post logs
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  logs:
                    type: array
                    items:
                      $ref: "#/components/schemas/PostLog"
        "404":
          description: Post group not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /webhooks:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    get:
      summary: List all webhooks
      description: Retrieve all webhooks configured for your account.
      operationId: listWebhooks
      tags:
        - Webhooks
      responses:
        "200":
          description: List of webhooks
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  webhooks:
                    type: array
                    items:
                      $ref: "#/components/schemas/Webhook"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      summary: Create a webhook
      description: |
        Create a new webhook endpoint. Maximum 10 webhooks per user.
        The webhook secret is only returned on creation - save it securely.
      operationId: createWebhook
      tags:
        - Webhooks
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url, events]
              properties:
                name:
                  type: string
                  description: "Friendly name for the webhook"
                  example: "Production webhook"
                url:
                  type: string
                  format: uri
                  description: "HTTPS URL to receive webhook events"
                  example: "https://example.com/webhooks/publora"
                events:
                  type: array
                  items:
                    type: string
                    enum: [post.scheduled, post.published, post.failed, post.demoted, token.expiring]
                  description: "Events to subscribe to. token.expiring is defined but not currently dispatched; post.demoted fires when an attach/detach returns a scheduled group to draft."
                  example: ["post.published", "post.failed"]
      responses:
        "201":
          description: Webhook created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  webhook:
                    type: object
                    properties:
                      _id:
                        type: string
                      name:
                        type: string
                      url:
                        type: string
                      events:
                        type: array
                        items:
                          type: string
                      secret:
                        type: string
                        description: "Webhook secret for signature verification (only returned on create)"
                      isActive:
                        type: boolean
                      createdAt:
                        type: string
                        format: date-time
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "Name, URL, and at least one event are required" }
                invalidUrl:
                  value: { "error": "URL must use HTTPS" }
                invalidEvents:
                  value: { "error": "Invalid events" }
                limitReached:
                  value: { "error": "Maximum of 10 webhooks per user" }
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /webhooks/{id}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    patch:
      summary: Update a webhook
      description: Update an existing webhook's name, URL, events, or active status.
      operationId: updateWebhook
      tags:
        - Webhooks
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  items:
                    type: string
                    enum: [post.scheduled, post.published, post.failed, post.demoted, token.expiring]
                    description: "token.expiring is defined and subscribable but is not currently dispatched"
                isActive:
                  type: boolean
      responses:
        "200":
          description: Webhook updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  webhook:
                    $ref: "#/components/schemas/Webhook"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      summary: Delete a webhook
      description: Delete a webhook endpoint.
      operationId: deleteWebhook
      tags:
        - Webhooks
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Webhook deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /webhooks/{id}/regenerate-secret:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Regenerate webhook secret
      description: |
        Generate a new secret for a webhook. The old secret will be invalidated.
        Make sure to update your webhook handler with the new secret.
      operationId: regenerateWebhookSecret
      tags:
        - Webhooks
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Secret regenerated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  secret:
                    type: string
                    description: "New webhook secret"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /linkedin-comments:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Add a comment to a LinkedIn post
      description: |
        Post a comment on a LinkedIn post. Supports replies to existing comments
        via the parentComment parameter. Raw input may be up to 10000 characters;
        after mention processing, the text sent to LinkedIn must be at most 1250 characters.
        Supports @mentions using @{urn:li:person:ID|Name} or @{urn:li:organization:ID|Company} syntax.
      operationId: createLinkedInComment
      tags:
        - LinkedIn Analytics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [postedId, message, platformId]
              properties:
                postedId:
                  type: string
                  description: "LinkedIn post URN (urn:li:share:*, urn:li:ugcPost:*, or urn:li:activity:*). Activity URNs are attempted directly, with same-ID ugcPost/share fallbacks for retryable LinkedIn target errors."
                  example: "urn:li:share:7123456789012345678"
                message:
                  type: string
                  description: "Raw comment input (max 10000 characters); after mention conversion, the sent text is limited to 1250 characters. Supports @{urn:li:person:ID|Name} and organization mentions."
                  example: "Great insights @{urn:li:person:ACoAABcD1234EfG|Jane Smith}! Thanks for sharing."
                platformId:
                  type: string
                  description: "LinkedIn connection ID"
                  example: "linkedin-Tz9W5i6ZYG"
                parentComment:
                  type: string
                  description: "Optional parent comment URN for replies"
                  example: "urn:li:comment:(urn:li:activity:123,456)"
      responses:
        "201":
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  comment:
                    type: object
                    properties:
                      id:
                        type: string
                        description: "Comment URN"
                      message:
                        type: string
                      actor:
                        type: string
                      object:
                        type: string
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "postedId, message, and platformId are required" }
                invalidUrn:
                  value: { "error": "postedId must be a valid LinkedIn URN" }
                emptyMessage:
                  value: { "error": "message cannot be empty" }
                rawMessageTooLong:
                  value: { "error": "comment text cannot exceed 10000 characters" }
                processedMessageTooLong:
                  value: { "error": "comment text cannot exceed 1250 characters after mention processing" }
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      summary: Delete a comment from a LinkedIn post
      description: |
        Remove a comment from a LinkedIn post.
        Parameters can be provided as query parameters or in the request body.
      operationId: deleteLinkedInComment
      tags:
        - LinkedIn Analytics
      parameters:
        - name: postedId
          in: query
          description: "LinkedIn post URN (can also be provided in request body)"
          schema:
            type: string
          example: "urn:li:share:7123456789012345678"
        - name: commentId
          in: query
          description: "Comment ID or URN (can also be provided in request body)"
          schema:
            type: string
          example: "6636062862760562688"
        - name: platformId
          in: query
          description: "LinkedIn connection ID (can also be provided in request body)"
          schema:
            type: string
          example: "linkedin-Tz9W5i6ZYG"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                postedId:
                  type: string
                commentId:
                  type: string
                platformId:
                  type: string
      responses:
        "200":
          description: Comment deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  deleted:
                    type: string
                    description: "Comment ID that was deleted"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                missingFields:
                  value: { "error": "postedId, commentId, and platformId are required" }
                invalidUrn:
                  value: { "error": "postedId must be a valid LinkedIn URN" }
        "404":
          description: LinkedIn connection not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ============================================================================
  # WORKSPACE API
  # To enable Workspace access, please contact Publora support at serge@publora.com
  # ============================================================================

  /workspace/users:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    get:
      summary: List managed users
      description: |
        Retrieve all users managed under your workspace.

        **Requires Workspace access - contact serge@publora.com to enable.**
      operationId: getManagedUsers
      tags:
        - Workspace
      responses:
        "200":
          description: List of managed users
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      $ref: "#/components/schemas/ManagedUser"
        "403":
          description: Workspace access not enabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Workspace access is not enabled for this key"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      summary: Create a managed user
      description: |
        Create a new managed user under your workspace.
        The user can then connect their social accounts via a connection URL.

        **Requires Workspace access - contact serge@publora.com to enable.**
      operationId: createManagedUser
      tags:
        - Workspace
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              properties:
                username:
                  type: string
                  format: email
                  description: "User's email address (must be unique)"
                  example: "user@example.com"
                displayName:
                  type: string
                  description: "Display name (defaults to email if not provided)"
                  example: "John Doe"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    $ref: "#/components/schemas/ManagedUser"
        "400":
          description: Missing required fields
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Email is required"
        "403":
          description: Workspace access not enabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            An account with this email already exists. The response includes
            `userId` and membership fields (`attachable`, `requiresConsent`,
            `reason`, `alreadyInWorkspace`, `managedByAnotherWorkspace`). When
            `attachable: true`, attach the user via POST /workspace/users/attach.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "User with this email already exists"
                code: "email_exists"
                userId: "6626a1f5e4b0c91a2d3f4567"
                alreadyInWorkspace: false
                managedByAnotherWorkspace: false
                reason: "standalone_user_exists"
                attachable: true
                requiresConsent: true
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /workspace/users/attach:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Attach an existing user
      description: |
        Attach an existing standalone Publora user to your workspace as a managed
        user. Because the target account already exists, this requires the target
        user's consent: a valid access token belonging to that user (the
        `accessToken` returned when they sign in), passed as `userAccessToken`.
        Re-attaching a user who is already in your workspace is idempotent and
        does not require a consent token.

        **Requires Workspace access - contact serge@publora.com to enable.**
      operationId: attachManagedUser
      tags:
        - Workspace
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  description: "The existing user's email. Provide either email or userId (also accepted as `username`)."
                  example: "user@example.com"
                userId:
                  type: string
                  description: "The existing user's ObjectId. Provide either email or userId."
                  example: "6626a1f5e4b0c91a2d3f4567"
                userAccessToken:
                  type: string
                  description: "The target user's access token, proving consent. Required unless the user is already in your workspace. Also accepted as `attachToken` in the body or via the `x-publora-user-token` header."
              example:
                email: "user@example.com"
                userAccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
      responses:
        "200":
          description: User attached (or already in your workspace)
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    $ref: "#/components/schemas/ManagedUser"
                  alreadyInWorkspace:
                    type: boolean
                    description: "true if the user was already managed by your workspace (idempotent re-attach)"
                    example: false
        "400":
          description: Missing/invalid identifier, mismatched email vs userId, or the owner's own account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Email or userId is required"
                code: "missing_identifier"
        "403":
          description: Workspace access not enabled, the target user's consent token is missing/invalid/mismatched, or attaching would exceed the plan's connection limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Target user consent token is required"
                code: "attach_consent_required"
        "404":
          description: No active user matches the identifier
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "User not found"
                code: "user_not_found"
        "409":
          description: The target user is inactive, a workspace owner, in another workspace, or has an active subscription
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "Cannot attach a user with an active subscription"
                code: "user_has_active_subscription"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /workspace/users/{userId}:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    delete:
      summary: Detach a managed user
      description: |
        Remove a user from your workspace. The user account is not deleted,
        but is no longer managed by your workspace.

        **Requires Workspace access - contact serge@publora.com to enable.**
      operationId: detachManagedUser
      tags:
        - Workspace
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
          example: "507f1f77bcf86cd799439011"
      responses:
        "200":
          description: User detached
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        "400":
          description: Invalid userId
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Workspace access not enabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: User not found or not managed by this key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /workspace/users/{userId}/api-key:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Generate API key for managed user
      description: |
        Generate a new API key for a managed user. This key can be used
        to make API calls on behalf of the managed user (e.g., create posts
        to their connected social accounts).

        **Requires Workspace access - contact serge@publora.com to enable.**
      operationId: generateManagedUserApiKey
      tags:
        - Workspace
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: API key generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "API key generated successfully"
                  apiKey:
                    type: string
                    description: "The generated API key (save it securely - shown only once)"
                    example: "sk_lx5abc.a1b2c3d4e5f6..."
                  userId:
                    type: string
        "400":
          description: Invalid userId
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Workspace or API access not enabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                noWorkspace:
                  value: { "error": "Workspace access is not enabled for this key" }
                noApiAccess:
                  value: { "error": "API access is not enabled for this workspace owner" }
        "404":
          description: User not found or not managed by this key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /workspace/users/{userId}/connection-url:
    parameters:
      - $ref: "#/components/parameters/XPubloraClient"
    post:
      summary: Get connection URL for managed user
      description: |
        Get or generate a URL that allows a managed user to connect their
        social media accounts. The URL expires after a configurable period
        (default: 90 days).

        **Requires Workspace access - contact serge@publora.com to enable.**
      operationId: getConnectionUrl
      tags:
        - Workspace
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                rotate:
                  type: boolean
                  description: "Force generation of a new token even if current is valid"
                  default: false
                expiresInDays:
                  type: integer
                  description: "Number of days until the token expires"
                  default: 90
                  example: 30
      responses:
        "200":
          description: Connection URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri
                    description: "URL for user to connect their social accounts"
                    example: "https://app.publora.com/connect/abc123..."
                  tokenExpiresAt:
                    type: string
                    format: date-time
                    description: "When the connection URL expires"
        "400":
          description: Invalid userId
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Workspace access not enabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: User not found or not managed by this key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
