openapi: 3.1.0
info:
  title: postme.live Public API
  version: 1.0.0
  summary: Programmatic access to brand-aware multi-platform posting
  description: |
    The postme.live Public API lets programs and AI agents draft, schedule, and
    publish social-media posts on behalf of an organization. Authentication is
    per-organization via API keys minted from `Settings → Developer`.

    **v1 policy** — all `POST /v1/posts` and `POST /v1/drafts` requests are
    persisted as drafts. The owning user must review and publish them from the
    web UI. Direct posting and scheduling will be enabled in a future version
    without changing the request shape.
  contact:
    name: postme.live
    url: https://postme.live
    email: hello@postme.live
  license:
    name: Proprietary

servers:
  - url: https://postme.live/api/v1
    description: Production
  - url: http://localhost:3000/api/v1
    description: Local development

security:
  - bearerAuth: []

tags:
  - name: Posts
    description: Create posts (drafts by default, scheduled with `scheduled_for`) and read their status.
  - name: Drafts
    description: Explicitly draft-only creation endpoint (rejects `scheduled_for`).
  - name: Channels
    description: Read the connected social accounts the API key can target.
  - name: Media
    description: Upload media (file or URL) for use in posts.

paths:
  /posts:
    post:
      tags: [Posts]
      summary: Create a post (draft by default; scheduled with scheduled_for)
      operationId: createPost
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PostCreate' }
      responses:
        '202':
          description: |
            Accepted — persisted as a draft for human review, or (when
            `scheduled_for` is set) as a `queued` post that publishes
            automatically at that time.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Post' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Quota exceeded (daily post cap or pending-schedule cap). `error.details` carries `used`, `limit`, `reset_at`, `upgrade_url`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /posts/{id}:
    get:
      tags: [Posts]
      summary: Get a post by id
      operationId: getPost
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Post' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /drafts:
    post:
      tags: [Drafts]
      summary: Create a draft
      description: |
        Always a draft. Use this endpoint to make draft-only intent explicit —
        unlike `POST /posts` it REJECTS `scheduled_for` (422), so an automation
        wired to /drafts can never accidentally schedule.
      operationId: createDraft
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PostCreate' }
      responses:
        '202':
          description: Accepted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Post' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /channels:
    get:
      tags: [Channels]
      summary: List connected channels
      description: Returns every connected social account across the org's brands.
      operationId: listChannels
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Channel' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /media:
    post:
      tags: [Media]
      summary: Upload media (file or URL)
      description: |
        Accepts either:
          - `multipart/form-data` with a `file` field, or
          - `application/json` with `{ "url": "https://..." }` — the server
            fetches the URL (HTTPS only, public IPs only, ≤1 GB).
      operationId: uploadMedia
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Media' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413':
          description: Payload too large (>1 GB).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: pml_live_<lookup>_<secret>

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A client-generated unique string (UUID recommended) that lets the
        server safely retry the same logical request. Required on every
        write. Replays within 24 hours return the original response.
      schema:
        type: string
        minLength: 8
        maxLength: 200

  responses:
    BadRequest:
      description: Malformed request.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, invalid, revoked, or expired API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found, or not owned by the calling organization.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyConflict:
      description: Same Idempotency-Key reused with a different request body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnprocessableEntity:
      description: Semantically invalid (e.g. channel not owned by this org).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the next attempt is allowed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Machine-readable error code.
              examples: [invalid_request, unauthorized, idempotency_conflict, rate_limited, not_found, unprocessable, quota_exceeded]
            message: { type: string }
            details:
              type: object
              additionalProperties: true

    PostCreate:
      type: object
      required: [caption, channels]
      properties:
        caption:
          type: string
          description: |
            The base caption. Used verbatim on any channel that doesn't
            provide its own caption override.
          minLength: 1
          maxLength: 5000
        channels:
          type: array
          minItems: 1
          maxItems: 25
          items: { $ref: '#/components/schemas/ChannelTarget' }
        media:
          type: array
          maxItems: 10
          items: { $ref: '#/components/schemas/MediaRef' }
        scheduled_for:
          type: string
          format: date-time
          description: |
            Schedule the post instead of drafting it. Must be ≥ 2 minutes in
            the future. The post is created with `status: queued`, requires at
            least one media item, consumes one post-quota slot (402
            `quota_exceeded` when the daily cap or pending-schedule cap is
            hit), and publishes automatically at this time. Cancel from the
            dashboard ("Cancel scheduled"). Omit to create a draft (the v1
            default). `POST /drafts` rejects this field.

    ChannelTarget:
      type: object
      required: [id]
      properties:
        id:
          type: string
          format: uuid
          description: Channel id (from `GET /channels`).
        caption:
          type: string
          description: Per-channel override. Falls back to the top-level `caption`.
        title:
          type: string
          description: Required for YouTube; ignored elsewhere.
        description:
          type: string
          description: YouTube description; ignored elsewhere.
        options:
          $ref: '#/components/schemas/ChannelOptions'

    ChannelOptions:
      type: object
      description: |
        Per-platform publish options. Keys are validated against the target
        channel's platform: an unknown key anywhere is a 400 `invalid_request`;
        a known key on the wrong platform (e.g. `video_title` on an Instagram
        channel) is a 422 `unprocessable`. Supported on `youtube`,
        `meta_instagram` and `meta_facebook` channels; other platforms do not
        accept `options` yet.
      additionalProperties: false
      properties:
        privacy_status:
          type: string
          enum: [public, unlisted, private]
          description: YouTube only.
        category_id:
          type: string
          description: YouTube only. Numeric category id (e.g. '22' = People & Blogs).
        tags:
          type: array
          items: { type: string, maxLength: 100 }
          maxItems: 50
          description: YouTube only. Combined length ≤ 500 characters.
        made_for_kids:
          type: boolean
          description: YouTube only. COPPA self-declaration.
        contains_synthetic_media:
          type: boolean
          description: YouTube only. AI / synthetic-media disclosure.
        share_to_story:
          type: boolean
          description: |
            Facebook + Instagram. After the feed post / reel publishes, also
            share the media to Stories. Stories carry no caption or link
            (Meta API limit). Story sharing is best-effort — a story failure
            never fails the main post.
        share_to_feed:
          type: boolean
          description: Instagram Reels only. `false` keeps the reel in the Reels tab, out of the main feed/grid.
        collaborators:
          type: array
          items: { type: string, maxLength: 60 }
          maxItems: 3
          description: Instagram only. Usernames invited as collaborators; the post appears on their profile once they accept.
        video_title:
          type: string
          maxLength: 255
          description: Facebook video posts only.
        image_alt_texts:
          type: array
          items: { type: string, maxLength: 1000 }
          maxItems: 10
          description: Facebook + Instagram. One alt text per attached image, by media order.

    MediaRef:
      oneOf:
        - type: object
          required: [id]
          properties:
            id:
              type: string
              format: uuid
              description: A media id from `POST /media`.
        - type: object
          required: [url]
          properties:
            url:
              type: string
              format: uri
              description: A public HTTPS URL. The server fetches it server-side.

    Post:
      type: object
      required: [id, status, channels, created_at]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [draft, queued, posting, partial, posted, failed]
        caption: { type: string }
        media_ids:
          type: array
          items: { type: string, format: uuid }
        channels:
          type: array
          items: { $ref: '#/components/schemas/PostChannelStatus' }
        scheduled_for:
          type: [string, 'null']
          format: date-time
        review_url:
          type: string
          description: Web URL where a human can review and publish the post.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        meta:
          type: object
          additionalProperties: true
          description: Free-form server hints. v1 returns `{ "note": "..." }` to surface the draft-only policy.

    PostChannelStatus:
      type: object
      required: [id, channel_id, status]
      properties:
        id: { type: string, format: uuid }
        channel_id: { type: string, format: uuid }
        platform:
          type: string
          enum: [meta_facebook, meta_instagram, tiktok, youtube, custom_webhook]
        status:
          type: string
          enum: [pending, posting, awaiting_platform, posted, failed, skipped]
        external_post_id: { type: [string, 'null'] }
        external_post_url: { type: [string, 'null'] }
        error_message: { type: [string, 'null'] }
        options:
          type: [object, 'null']
          additionalProperties: true
          description: The public per-platform options stored on this channel target (same keys as `ChannelOptions`).

    Channel:
      type: object
      required: [id, platform, display_name, status, business_id]
      properties:
        id: { type: string, format: uuid }
        platform:
          type: string
          enum: [meta_facebook, meta_instagram, tiktok, youtube, custom_webhook]
        display_name: { type: string }
        avatar_url: { type: [string, 'null'] }
        status:
          type: string
          enum: [active, expired, revoked, disconnected]
        business_id: { type: string, format: uuid }
        business_name: { type: string }

    Media:
      type: object
      required: [id, kind, bytes, mime, url]
      properties:
        id: { type: string, format: uuid }
        kind:
          type: string
          enum: [image, video]
        bytes: { type: integer }
        mime: { type: string }
        filename: { type: [string, 'null'] }
        url: { type: string, description: Public-readable URL to the original asset. }
        thumbnail_status:
          type: string
          enum: [pending, processing, ready, failed]
