openapi: 3.1.0
info:
  title: PosteAhora Public API
  version: "1.0.0"
  description: >
    Key-authenticated REST gateway over the same publishing pipeline as the
    PosteAhora web app. It powers the MCP server, the CLI, the n8n node, and any
    third-party integration. This is the frozen **v1** contract: additive
    changes only.


    Human-readable docs: https://posteahora.com/docs/api
  contact:
    name: PosteAhora
    url: https://posteahora.com/docs/api
servers:
  - url: https://api.posteahora.com/functions/v1/api
    description: Production. The /functions/v1/ segment is part of the stable URL — do not strip it.
security:
  - ApiKey: []
tags:
  - name: Accounts
  - name: Ideas
  - name: Posts
  - name: Analytics
  - name: Media

paths:
  /accounts:
    get:
      tags: [Accounts]
      summary: List connected social accounts (in the key's workspace)
      description: Call this first — publishing needs each account's id. Tokens are never returned.
      security:
        - ApiKey: [] # requires scope accounts:read
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { $ref: "#/components/schemas/Account" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces:
    get:
      tags: [Accounts]
      summary: List the workspaces you belong to
      description: A key is bound to one workspace; active=true marks the one this key acts in.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  workspaces:
                    type: array
                    items: { $ref: "#/components/schemas/Workspace" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /ideas:
    get:
      tags: [Ideas]
      summary: List ideas (backlog), ordered by kanban column then position
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ideas: { type: array, items: { $ref: "#/components/schemas/Idea" } }
    post:
      tags: [Ideas]
      summary: Create an idea
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdeaInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties: { idea: { $ref: "#/components/schemas/Idea" } }

  /ideas/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    patch:
      tags: [Ideas]
      summary: Update an idea (only sent fields are touched)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdeaInput" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { idea: { $ref: "#/components/schemas/Idea" } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Ideas]
      summary: Delete an idea
      responses:
        "200": { $ref: "#/components/responses/Deleted" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posts:
    get:
      tags: [Posts]
      summary: List posts
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [draft, scheduled, queued, published, partial_published, failed] }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  posts: { type: array, items: { $ref: "#/components/schemas/Post" } }
    post:
      tags: [Posts]
      summary: Create a draft, schedule, or publish now (controlled by status)
      description: >
        Multi-platform posts fan out into one row per platform at publish time.
        Publishing is asynchronous — a "published" create returns status "queued";
        poll GET /posts/{id} and read platform_results.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePostRequest" }
            examples:
              multiPlatformVideo:
                summary: One video → YouTube Shorts + TikTok + Instagram Reels, per-platform metadata
                value:
                  caption: Fallback text if a platform override is missing
                  mediaUrls: ["https://cdn.posteahora.com/…/clip.mp4"]
                  mediaType: video
                  postType: reel
                  status: published
                  accountMappings:
                    - { platform: youtube, accountId: "<yt-account-id>" }
                    - { platform: tiktok, accountId: "<tt-account-id>" }
                    - { platform: instagram, accountId: "<ig-account-id>" }
                  platformCaptions:
                    youtube: "Full YouTube description — links, timestamps, etc."
                    tiktok: "TikTok caption #fyp"
                    instagram: "Reel caption for IG ✨"
                  platformOptions:
                    youtube: { title: "My video title", categoryId: "22", madeForKids: false, privacyStatus: public, tags: [demo, api] }
                    tiktok: { privacyLevel: PUBLIC_TO_EVERYONE, disableComment: false, brandContentToggle: false, brandOrganicToggle: false, isAigc: false }
                    instagram: { shareToFeed: true, coverUrl: "https://cdn.posteahora.com/…/cover.jpg" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatePostResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402":
          description: Monthly post quota reached
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /posts/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      tags: [Posts]
      summary: Read one post (includes connected_account_id and platform_results)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { post: { $ref: "#/components/schemas/Post" } }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Posts]
      summary: Edit a draft or scheduled post (only sent fields are touched)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdatePostRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { post: { $ref: "#/components/schemas/Post" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Posts]
      summary: Soft-delete a post
      responses:
        "200": { $ref: "#/components/responses/Deleted" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posts/{id}/publish:
    parameters:
      - $ref: "#/components/parameters/Id"
    post:
      tags: [Posts]
      summary: Publish an existing draft/scheduled post immediately
      description: Enters the async pipeline. Poll GET /posts/{id} for platform_results.
      responses:
        "200":
          description: Queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatePostResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /analytics:
    get:
      tags: [Analytics]
      summary: Read post analytics (refreshed hourly)
      parameters:
        - name: period
          in: query
          schema: { type: string, enum: [7d, 30d, 90d, all], default: 30d }
        - name: platform
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 200, maximum: 500 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsResult" }

  /media/upload-url:
    post:
      tags: [Media]
      summary: Get a presigned URL to upload media, then PUT the file to it
      description: Required for TikTok photos (must be served from cdn.posteahora.com). Works for any size.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                filename: { type: string }
                contentType: { type: string }
                sizeBytes: { type: integer }
                prefix: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  uploadUrl: { type: string, format: uri }
                  publicUrl: { type: string, format: uri }

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: >
        A PosteAhora API key: `Authorization: Bearer pah_live_…` (or `pah_test_…`).
        Keys are created in the app under /api and carry scopes (ideas:read/write,
        posts:read/write, analytics:read, accounts:read, media:write). A missing
        scope returns 403.

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Key lacks the required scope (or viewer-role key attempting a write)
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Resource not found (or not in the key's workspace)
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    BadRequest:
      description: Invalid request
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Deleted:
      description: Deleted
      content:
        application/json:
          schema:
            type: object
            properties:
              id: { type: string }
              deleted: { type: boolean }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
      required: [error]

    Account:
      type: object
      properties:
        id: { type: string, format: uuid }
        platform: { type: string, example: instagram }
        platform_username: { type: string }
        platform_account_id: { type: string }
        is_connected: { type: boolean }
        requires_reauth: { type: boolean }
        profile_image_url: { type: [string, "null"], format: uri }
        connected_at: { type: string, format: date-time }

    Workspace:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        isPersonal: { type: boolean }
        role: { type: string, enum: [owner, admin, manager, member, viewer] }
        active: { type: boolean }

    IdeaInput:
      type: object
      properties:
        title: { type: string }
        caption: { type: string }
        tags: { type: array, items: { type: string } }
        mediaUrls: { type: array, items: { type: string, format: uri } }
        mediaType: { type: string, enum: [image, video] }
        status: { type: string, enum: [unassigned, todo, in_progress, done], default: unassigned }

    Idea:
      allOf:
        - $ref: "#/components/schemas/IdeaInput"
        - type: object
          properties:
            id: { type: string, format: uuid }
            position: { type: number }

    AccountMapping:
      type: object
      properties:
        platform: { type: string, example: youtube }
        accountId:
          type: string
          description: connected_accounts.id. Required for every network except a few that don't need account selection.
      required: [platform]

    CreatePostRequest:
      type: object
      properties:
        title: { type: string }
        caption: { type: string, description: Shared caption. Per-network override via platformCaptions. }
        accountMappings:
          type: array
          items: { $ref: "#/components/schemas/AccountMapping" }
          description: Canonical channel selection. Prefer this over platforms+accountId.
        platforms:
          type: array
          items: { type: string }
          description: Convenience form for a single-channel post (combine with accountId).
        accountId:
          type: string
          description: Used only with the platforms convenience form.
        mediaUrls: { type: array, items: { type: string, format: uri } }
        mediaType: { type: string, enum: [image, video] }
        postType: { type: string, enum: [post, reel, story], default: post }
        hashtags: { type: array, items: { type: string } }
        platformCaptions:
          type: object
          additionalProperties: { type: string }
          description: Per-platform caption/description override, keyed by platform id.
        platformOptions: { $ref: "#/components/schemas/PlatformOptions" }
        status: { type: string, enum: [draft, scheduled, published], default: draft }
        scheduledAt:
          type: string
          format: date-time
          description: Required (and must be in the future) when status = scheduled.
      required: [accountMappings]

    UpdatePostRequest:
      type: object
      description: Edit a draft or scheduled post. Only sent fields are touched.
      properties:
        title: { type: string }
        caption: { type: string }
        hashtags: { type: array, items: { type: string } }
        mediaUrls: { type: array, items: { type: string, format: uri } }
        mediaType: { type: string, enum: [image, video] }
        postType: { type: string, enum: [post, reel, story] }
        platforms: { type: array, items: { type: string } }
        connectedAccountId: { type: string }
        platformOptions: { $ref: "#/components/schemas/PlatformOptions" }
        status: { type: string, enum: [draft, scheduled] }
        scheduledAt: { type: string, format: date-time }

    CreatePostResult:
      type: object
      properties:
        postIds: { type: array, items: { type: string, format: uuid } }
        status: { type: string, enum: [draft, scheduled, queued] }
        dispatchDeferred:
          type: boolean
          description: When true, rows are stored but the worker wasn't reached synchronously; the cron sweep will publish them.

    PlatformResult:
      type: object
      properties:
        platform: { type: string }
        success: { type: boolean }
        error: { type: string }
        remote_post_id: { type: string }
        remote_publish_id: { type: string }
        remote_permalink: { type: string, format: uri }
      required: [platform, success]

    Post:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: [string, "null"] }
        caption: { type: [string, "null"] }
        platforms: { type: array, items: { type: string } }
        status: { type: string, enum: [draft, scheduled, queued, published, partial_published, failed] }
        scheduled_at: { type: [string, "null"], format: date-time }
        published_at: { type: [string, "null"], format: date-time }
        media_urls: { type: array, items: { type: string, format: uri } }
        media_type: { type: [string, "null"], enum: [image, video, null] }
        connected_account_id: { type: [string, "null"], format: uuid }
        platform_results:
          type: array
          items: { $ref: "#/components/schemas/PlatformResult" }
        created_at: { type: string, format: date-time }

    AnalyticsResult:
      type: object
      properties:
        period: { type: string }
        summary:
          type: object
          properties:
            totals: { type: object, additionalProperties: { type: number } }
            byPlatform: { type: object, additionalProperties: { type: object, additionalProperties: { type: number } } }
        posts:
          type: array
          items:
            type: object
            properties:
              postId: { type: string }
              caption: { type: [string, "null"] }
              publishedAt: { type: [string, "null"], format: date-time }
              platform: { type: string }
              views: { type: [number, "null"] }
              likes: { type: [number, "null"] }
              comments: { type: [number, "null"] }
              shares: { type: [number, "null"] }
              reach: { type: [number, "null"] }
              saves: { type: [number, "null"] }
              impressions: { type: [number, "null"] }
              reposts: { type: [number, "null"] }
              totalInteractions: { type: [number, "null"] }
              fetchedAt: { type: [string, "null"], format: date-time }
              error: { type: [string, "null"] }

    # --- Per-network platformOptions -------------------------------------
    # Keyed by platform id. Any key the dispatcher doesn't read is ignored.
    PlatformOptions:
      type: object
      description: Per-network settings, keyed by platform id.
      properties:
        youtube: { $ref: "#/components/schemas/YouTubeOptions" }
        tiktok: { $ref: "#/components/schemas/TikTokOptions" }
        instagram: { $ref: "#/components/schemas/InstagramOptions" }
        threads: { $ref: "#/components/schemas/ThreadsOptions" }
        bluesky: { $ref: "#/components/schemas/BlueskyOptions" }
        mastodon: { $ref: "#/components/schemas/MastodonOptions" }
        telegram: { $ref: "#/components/schemas/TelegramOptions" }
        discord: { $ref: "#/components/schemas/DiscordOptions" }
      additionalProperties: true

    YouTubeOptions:
      type: object
      description: >
        The video description is the post caption (platformCaptions.youtube,
        ≤5000 chars). The video title is the required `title` field. There is no
        "Shorts" flag — a vertical clip ≤3 min is auto-classified as a Short.
      properties:
        title: { type: string, maxLength: 100 }
        categoryId: { type: string, example: "22" }
        madeForKids: { type: boolean }
        privacyStatus: { type: string, enum: [public, unlisted, private], default: private }
        tags: { type: array, items: { type: string } }
        defaultLanguage: { type: string }
        containsSyntheticMedia: { type: boolean }
        embeddable: { type: boolean }
        publicStatsViewable: { type: boolean }
        license: { type: string, enum: [youtube, creativeCommon] }
        thumbnailUrl: { type: string, format: uri }
      required: [title, categoryId, madeForKids]

    TikTokOptions:
      type: object
      properties:
        privacyLevel:
          type: string
          enum: [PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY]
        disableComment: { type: boolean }
        brandContentToggle: { type: boolean }
        brandOrganicToggle: { type: boolean }
        isAigc: { type: boolean }
        postMode: { type: string, enum: [DIRECT_POST, MEDIA_UPLOAD], default: DIRECT_POST }
        disableDuet: { type: boolean, description: video only }
        disableStitch: { type: boolean, description: video only }
        videoCoverTimestampMs: { type: number, description: video only }
        autoAddMusic: { type: boolean, description: photo only }
        photoCoverIndex: { type: number, description: photo only }
        photoTitle: { type: string, maxLength: 90, description: photo only }
      required: [privacyLevel, disableComment, brandContentToggle, brandOrganicToggle, isAigc]

    InstagramOptions:
      type: object
      description: Set postType "reel" (top level) for a Reel, "story" for a Story.
      properties:
        shareToFeed: { type: boolean }
        coverUrl: { type: string, format: uri }
        thumbOffsetMs: { type: number }
        audioName: { type: string }
        collaborators: { type: array, items: { type: string } }
        locationId: { type: string }
        userTags:
          type: array
          items:
            type: object
            properties:
              username: { type: string }
              x: { type: number }
              y: { type: number }
            required: [username]
        altText: { type: string }
        isAiGenerated: { type: boolean }
        isTrialReel: { type: boolean, description: reel only }
        trialGraduationStrategy: { type: string, enum: [MANUAL, SS_PERFORMANCE], description: reel only }

    ThreadsOptions:
      type: object
      properties:
        replyControl:
          type: string
          enum: [everyone, accounts_you_follow, mentioned_only, parent_post_author_only, followers_only]
        topic: { type: string }
        altText: { type: string }
        linkAttachment: { type: string, format: uri }
        quotePostId: { type: string }
        locationId: { type: string }
        allowlistedCountryCodes: { type: array, items: { type: string } }
        chainItems:
          type: array
          items:
            type: object
            properties:
              text: { type: string }
              mediaUrl: { type: string, format: uri }
              mediaType: { type: string, enum: [image, video] }
              altText: { type: string }
            required: [text]

    BlueskyOptions:
      type: object
      properties:
        langs: { type: array, items: { type: string } }

    MastodonOptions:
      type: object
      properties:
        visibility: { type: string, enum: [public, unlisted, private, direct] }
        spoilerText: { type: string }
        sensitive: { type: boolean }
        language: { type: string }

    TelegramOptions:
      type: object
      properties:
        disableNotification: { type: boolean }
        protectContent: { type: boolean }

    DiscordOptions:
      type: object
      properties:
        channelId: { type: string }
