openapi: 3.1.0
info:
  title: XreplyAI Public API
  version: "2.12.0"
  description: |
    The XreplyAI Public API lets you programmatically schedule and publish
    posts, generate content with your trained voice, upload media, read your
    connected social accounts, and pull post analytics — across X, LinkedIn,
    YouTube, Instagram, Threads, Pinterest, TikTok, Bluesky, Mastodon, Facebook,
    Google Business, Discord, Telegram, Tumblr, and Slack.

    ## Authentication

    Authenticate with a personal API key. Create one in the dashboard under
    **Settings → API Keys** (this requires any paid plan — Starter, Pro, or
    Team; a key whose account later loses API access is rejected at use with
    `401 UNAUTHORIZED`). Pass the key as a bearer token on every request:

    ```
    Authorization: Bearer <your-api-key>
    ```

    Key rules:

    - **One active key per workspace.** Creating a second key while one is
      active returns `422 API_KEY_LIMIT_REACHED` — revoke the existing key
      first.
    - **Keys are pinned to the workspace that created them.** If you leave that
      workspace, the key stops working and returns `403 WORKSPACE_FORBIDDEN`.
    - **The secret is shown once**, at creation. Store it securely; it cannot be
      retrieved again.
    - **Keys never expire** but can be revoked from the dashboard at any time.

    Each key carries a fixed set of **scopes** chosen at creation. A request
    that exceeds a key's scopes returns `403 INSUFFICIENT_SCOPE`. Key
    management, billing mutations, and account-identity endpoints are
    intentionally **not** reachable with an API key — those require a dashboard
    session. Endpoints not opted into API-key access return
    `403 ENDPOINT_NOT_AVAILABLE`.

    ## Scopes

    A key is granted a subset of these scopes at creation. Endpoints document
    the scope they require.

    | Scope                   | Grants                                                        |
    |-------------------------|---------------------------------------------------------------|
    | `posts:read`            | List and read posts; read the next available posting slot     |
    | `posts:write`           | Create, update, delete, bulk-create, and approve/reject posts |
    | `ai:generate`           | Generate post content, threads, batches, and carousels        |
    | `publish:write`         | Publish posts now or to a slot; upload media                  |
    | `accounts:read`         | List connected social accounts; search IG audio; list boards  |
    | `analytics:read`        | Read analytics rollups, timeseries, and best-time data        |
    | `preferences:read`      | Read generation preferences                                   |
    | `preferences:write`     | Update generation preferences                                 |
    | `platform_styles:read`  | Read per-platform style profiles                              |
    | `platform_styles:write` | Update or reset per-platform style profiles                   |
    | `rules:read`            | List and read custom rules                                    |
    | `rules:write`           | Create, update, delete, and toggle custom rules               |
    | `schedules:read`        | Read posting schedules                                        |
    | `schedules:write`       | Update a posting schedule                                     |
    | `plans:read`            | Read content plans                                            |
    | `voice:read`            | Read the voice profile                                        |
    | `communities:read`      | List saved X (Twitter) communities                            |
    | `billing:read`          | Read the current subscription and quota                       |
    | `image_week:read`       | List and read Quote Week (image generation) runs              |
    | `image_week:write`      | Start, cancel, and regenerate items in Quote Week runs         |

    ## Pagination

    List endpoints that can grow (currently `GET /posts`) accept `limit` and
    `offset` query parameters and return a `meta` envelope:

    ```json
    { "meta": { "limit": 100, "offset": 0, "total_count": 342 } }
    ```

    `limit` defaults to **100** and is capped at **200**; values above 200 are
    clamped. `offset` defaults to 0. Page by incrementing `offset` by your
    `limit` until `offset + limit >= total_count`.

    ## Rate limits

    Every `/api/v1/*` response carries IETF-style rate-limit headers:

    - `RateLimit-Limit` — requests allowed in the current window
    - `RateLimit-Remaining` — requests left in the window
    - `RateLimit-Reset` — seconds until the window resets

    A global ceiling of **100 requests/minute per IP** applies to all endpoints.
    Specific endpoints have tighter per-token limits:

    | Endpoint(s)                                                            | Limit           |
    |------------------------------------------------------------------------|-----------------|
    | `POST /posts/generate`                                                 | 10 / minute     |
    | `POST /posts/generate_batch`, `/generate_thread`, `/generate_carousel` | 6 / minute (shared bucket) |
    | `POST /posts/{post_id}/publishes`                                      | 30 / minute     |
    | `POST /linkedin/video_uploads`                                         | 10 / hour       |

    On `429` the response body is
    `{ "error": { "message": "...", "code": "RATE_LIMIT_EXCEEDED", "retry_after": <seconds> } }`
    and a `Retry-After` header (seconds) is included. AI-generation endpoints
    additionally return `429 DAILY_LIMIT_EXCEEDED` when your plan's daily
    generation quota is exhausted.

    ## Errors

    All errors share one envelope and use correct HTTP status codes:

    ```json
    { "error": { "message": "Insufficient scope. This action requires: posts:write.", "code": "INSUFFICIENT_SCOPE" } }
    ```

    `details` may be present (e.g. an array of validation messages) on
    validation errors.

    | Code                    | HTTP | Meaning                                                             |
    |-------------------------|------|---------------------------------------------------------------------|
    | `UNAUTHORIZED`          | 401  | Missing or invalid API key.                                         |
    | `ENDPOINT_NOT_AVAILABLE`| 403  | This endpoint is not reachable with an API key.                     |
    | `INSUFFICIENT_SCOPE`    | 403  | The key lacks the scope this action requires.                       |
    | `NO_ACTIVE_SUBSCRIPTION`| 403  | The workspace has no active paid subscription.                      |
    | `TIER_UPGRADE_REQUIRED` | 403  | The action requires a higher plan (e.g. recurring posts, approvals).|
    | `WORKSPACE_FORBIDDEN`   | 403  | The key's creator no longer belongs to the key's workspace.         |
    | `NOT_FOUND`             | 404  | The requested resource does not exist or is not accessible.         |
    | `INVALID_PARAMETERS`    | 422  | Request parameters failed validation.                               |
    | `API_KEY_LIMIT_REACHED` | 422  | The workspace already has an active API key.                        |
    | `PLAN_LIMIT_REACHED`    | 422  | The user already has the maximum of 10 content plans.               |
    | `INVALID_STATE`         | 422  | The resource isn't in a state this action can be applied to.        |
    | `GENERATION_FAILED`     | 422  | AI generation could not produce content.                            |
    | `quota_exceeded`        | 422  | The monthly Quote Week (image generation) run quota is exhausted.   |
    | `RATE_LIMIT_EXCEEDED`   | 429  | Per-IP or per-endpoint rate limit exceeded.                         |
    | `DAILY_LIMIT_EXCEEDED`  | 429  | The plan's daily AI-generation quota is exhausted.                  |

    ## Webhooks

    Register webhook endpoints in the dashboard under **Settings → API Keys →
    Webhooks** (webhook management is dashboard-only — not reachable with an
    API key). XreplyAI POSTs a JSON payload to your HTTPS endpoint when a
    scheduled post reaches a terminal state:

    | Event                  | Fires when                                        |
    |------------------------|---------------------------------------------------|
    | `post.published`       | Every target platform published successfully.     |
    | `post.failed`          | Every target platform failed.                     |
    | `post.partial_failure` | Some platforms published, some failed.            |

    Payload:

    ```json
    {
      "id": "b3f1e2a0-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
      "event": "post.published",
      "post_id": 123,
      "occurred_at": "2026-07-14T09:00:12Z",
      "data": {
        "status": "posted",
        "platforms": [
          { "platform": "twitter", "platform_status": "posted",
            "platform_post_id": "1811223344556677889", "platform_error": null }
        ]
      }
    }
    ```

    The `id` field is a UUID generated once when the delivery is created and
    frozen into the stored payload, so it stays identical across every retry
    or manual redelivery of that event. Use it as a dedupe key on your end
    (e.g. an insert-on-unique-index against `id`) since the same event can
    otherwise arrive more than once.

    **Signatures and headers.** Every delivery carries an
    `X-XReplyAI-Signature: t=<unix-timestamp>,v1=<hex-digest>` header, where
    the digest is `HMAC-SHA256(secret, "<t>.<raw-request-body>")` using the
    webhook's secret (shown once, at creation). Verify by recomputing the
    digest and comparing with a constant-time comparison; reject stale
    timestamps (e.g. older than 5 minutes) to prevent replay. Two additional
    headers ride along on every attempt: `X-XReplyAI-Event-Id` (the same
    dedupe key as the payload's `id`, readable without parsing the body) and
    `X-XReplyAI-Attempt` (starts at `1`, increments on each retry or manual
    redelivery).

    **Delivery policy.** Endpoints must be HTTPS and respond 2xx within 5
    seconds. Failed deliveries are retried up to 7 times, with waits of 10s,
    100s, 15m, 1h, 2h, then 3h between attempts (about 6.5 hours from first
    attempt to final exhaustion). After 10 consecutive failed *events* a
    webhook is automatically disabled; re-enable it from the dashboard.
    Webhook management also exposes a per-delivery log (status, attempt
    count, last error) and manual redelivery of any individual delivery —
    dashboard-only, like the rest of webhook management. A test delivery
    (`webhook.test`) can be fired from the dashboard at any time. Up to 5
    webhooks per account.

    ## Versioning

    The API is versioned in the path (`/api/v1`). Within v1 the contract is
    additive-only: fields are never removed or renamed. Breaking changes ship
    under a future `/api/v2`.

    ## Changelog

    - **2.12.0** (2026-08-11) — `/platforms` `content_types` now reports
      per-platform truth (previously claimed every content type for every
      platform).
    - **1.15.0** (2026-08-03) — Outbound webhooks: payload now includes a
      stable `id` (UUID) dedupe key, plus `X-XReplyAI-Event-Id` and
      `X-XReplyAI-Attempt` headers on every delivery. Retry schedule
      extended from 5 attempts/polynomial backoff to 7 attempts over a
      fixed 10s/100s/15m/1h/2h/3h schedule (~6.5 hours total). Webhook
      management remains dashboard-only (not reachable via API key), so no
      new paths were added to this spec — see the Webhooks section above.
    - **1.4.0** (2026-07-21) — Quote Week (AI image generation) is now available
      to API keys: `GET/POST /image_week_runs`, `GET /image_week_runs/{id}`,
      `POST /image_week_runs/{id}/cancel`, and
      `POST /image_week_runs/{image_week_run_id}/items/{id}/regenerate`. New
      scopes `image_week:read` and `image_week:write`.
    - **1.3.0** (2026-07-15) — Developer-experience additions: `GET /platforms`
      (per-platform capabilities), a `permalink` field on publish results,
      `use_next_slot` and `dry_run` on `POST /posts` and `/posts/bulk`,
      `scheduled_after`/`scheduled_before`/`platform` filters on `GET /posts`,
      and `media` accepted as an alias for `image` on the X and LinkedIn image
      upload endpoints.
    - **1.2.0** (2026-07-14) — Outbound webhooks: `post.published`,
      `post.failed`, and `post.partial_failure` events with HMAC-SHA256
      signed deliveries, retries, and auto-disable. Registered and managed
      from the dashboard.
    - **1.1.0** (2026-07-13) — Documented the full API-key surface: analytics,
      publishing, media uploads, and the preferences, custom-rules,
      platform-styles, schedules, content-plans, voice, communities, and
      billing endpoints. Added pagination, the complete error catalog, and a
      real rate-limit table.
    - **1.0.0** (2026-06-22) — Initial public spec (account, posts, single
      generate, social accounts).

servers:
  - url: https://api.xreplyai.com/api/v1
    description: Production

security:
  - ApiKey: []

tags:
  - name: Account
    description: The authenticated identity behind the key.
  - name: Posts
    description: Create, read, update, schedule, and review posts.
  - name: AI Generation
    description: Generate post content in your trained voice.
  - name: Publishing
    description: Publish posts now or into the next open schedule slot.
  - name: Media Uploads
    description: Upload images and video for use in posts.
  - name: Social Accounts
    description: Read connected accounts and platform lookups.
  - name: Analytics
    description: Post-performance rollups, timeseries, and best-time data.
  - name: Preferences & Styles
    description: Generation preferences and per-platform style profiles.
  - name: Rules
    description: Custom rules applied to generated content.
  - name: Schedules & Plans
    description: Posting schedules and content plans.
  - name: Voice
    description: The trained voice profile.
  - name: Billing
    description: Read-only subscription and quota.
  - name: Quote Week
    description: AI-generated quote-card image runs (Instagram/Pinterest).
  - name: Site Pushes
    description: Push articles from a connected CMS site (e.g. the WordPress plugin). Authenticated by a Site Connection token, not a personal API key.

paths:
  /users/me:
    get:
      tags: [Account]
      operationId: getCurrentUser
      summary: Get the authenticated account
      description: Returns the account profile, subscription tier, and workspace context. Requires any valid key (no specific scope).
      responses:
        "200":
          description: The authenticated user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
              example:
                user:
                  id: 4021
                  email: "founder@example.com"
                  tier: "pro"
                  ai_credits_remaining: null
                  subscription_status: "active"
                  subscription_ends_at: "2026-08-01T00:00:00Z"
                  cancel_at_period_end: false
                  has_active_subscription: true
                  created_at: "2026-01-14T09:12:00Z"
                  team: { id: 88, name: "Acme", role: "admin" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /posts:
    get:
      tags: [Posts]
      operationId: listPosts
      summary: List posts
      description: |
        Requires scope: `posts:read`. Returns the post queue with status counts
        and a pagination envelope. Defaults to queue statuses (draft, scheduled,
        etc.) unless `status` is supplied.
      parameters:
        - name: status
          in: query
          description: Comma-separated statuses to include (e.g. `draft,scheduled,posted`).
          schema: { type: string }
        - name: source
          in: query
          description: Filter to a single source (e.g. `api`, `blog`, `onboarding`).
          schema: { type: string }
        - name: scheduled_after
          in: query
          description: Only return posts scheduled at or after this ISO 8601 UTC time.
          schema: { type: string, format: date-time }
        - name: scheduled_before
          in: query
          description: Only return posts scheduled at or before this ISO 8601 UTC time.
          schema: { type: string, format: date-time }
        - name: platform
          in: query
          description: Only return posts targeting this platform (e.g. `twitter`, `linkedin`).
          schema: { type: string }
        - name: sort
          in: query
          description: Sort field (e.g. `scheduled_at`, `created_at`).
          schema: { type: string }
        - name: sort_direction
          in: query
          schema: { type: string, enum: [asc, desc] }
        - name: limit
          in: query
          description: Page size. Default 100, maximum 200.
          schema: { type: integer, default: 100, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: A page of posts, with status counts and pagination meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  posts:
                    type: array
                    items: { $ref: "#/components/schemas/Post" }
                  counts: { $ref: "#/components/schemas/PostCounts" }
                  meta: { $ref: "#/components/schemas/PageMeta" }
              example:
                posts:
                  - id: 9001
                    status: "scheduled"
                    scheduled_at: "2026-07-14T15:00:00Z"
                    source: "api"
                    post_contents:
                      - id: 5501
                        platform: "twitter"
                        body: "Shipping something small today."
                        content_type: "text"
                        position: 0
                    body: "Shipping something small today."
                counts: { scheduled: 12, draft: 3, posted: 140, failed: 1 }
                meta: { limit: 100, offset: 0, total_count: 156 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Posts]
      operationId: createPost
      summary: Create a post
      description: |
        Requires scope: `posts:write`. Supply `post_contents` (one entry per
        platform) and `social_account_ids`. Omit `scheduled_at` to create a
        draft. Recurrence fields require a paid plan.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PostInput" }
            example:
              post_contents:
                - platform: "twitter"
                  body: "Shipping something small today."
                  content_type: "text"
              social_account_ids: [301]
              scheduled_at: "2026-07-14T15:00:00Z"
      responses:
        "201":
          description: The created post.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /posts/bulk:
    post:
      tags: [Posts]
      operationId: bulkCreatePosts
      summary: Create up to 50 posts in one call
      description: |
        Requires scope: `posts:write`. Each item is created in its own
        transaction, so a single invalid item does not roll back the others.
        Returns `201` if any item succeeded, `422` if all failed or if more than
        50 items were supplied.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                posts:
                  type: array
                  maxItems: 50
                  items: { $ref: "#/components/schemas/PostInput" }
              required: [posts]
            example:
              posts:
                - post_contents: [{ platform: "twitter", body: "First." }]
                  social_account_ids: [301]
                - post_contents: [{ platform: "linkedin", body: "Second." }]
                  social_account_ids: [302]
      responses:
        "201":
          description: Per-item results.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkResult" }
              example:
                created: [{ id: 9101, status: "draft" }]
                errors: [{ index: 1, message: "Body can't be blank" }]
        "422":
          description: All items failed, or more than 50 items were supplied.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /posts/batch_approve:
    post:
      tags: [Posts]
      operationId: batchApprovePosts
      summary: Approve multiple posts at once
      description: "Requires scope: `posts:write`, a Pro/Team plan, and a write role. Approves the supplied post IDs."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                post_ids:
                  type: array
                  items: { type: integer }
              required: [post_ids]
            example: { post_ids: [9001, 9002, 9003] }
      responses:
        "200":
          description: Approval results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approved:
                    type: array
                    items: { type: integer }
              example: { approved: [9001, 9002, 9003] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /posts/generate:
    post:
      tags: [AI Generation]
      operationId: generatePost
      summary: Generate post content in your voice
      description: "Requires scope: `ai:generate` and a paid plan. Subject to your plan's daily generation quota."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                topic: { type: string, maxLength: 280, description: "What to write about." }
                platform: { $ref: "#/components/schemas/Platform" }
                angle:
                  type: string
                  enum: [one_liner, list, question, story_arc, paragraph, opinion, my_voice]
            example: { topic: "why solo founders should post daily", platform: "twitter", angle: "opinion" }
      responses:
        "200":
          description: Generated content.
          content:
            application/json:
              schema:
                type: object
                properties:
                  body: { type: string }
              example: { body: "Posting daily isn't about volume. It's about staying in the room." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts/generate_batch:
    post:
      tags: [AI Generation]
      operationId: generatePostBatch
      summary: Generate a batch of post ideas
      description: |
        Requires scope: `ai:generate` and a paid plan. Generates several
        standalone posts for a category. Shares a 6/min rate-limit bucket with
        the thread and carousel generators.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                category: { type: string, enum: [personalized, trending, viral] }
                count: { type: integer, minimum: 1, maximum: 9, default: 6 }
                platform: { $ref: "#/components/schemas/Platform" }
              required: [category]
            example: { category: "personalized", count: 6, platform: "twitter" }
      responses:
        "200":
          description: Generated posts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  posts:
                    type: array
                    items: { type: string }
                  degraded_count:
                    type: integer
                    description: "How many of the returned posts were generated with the default model instead of the premium model you selected, because the AI balance ran out mid-batch. 0 when all used your selected model. A batch can straddle: the balance is checked per generation, so 6 requested with funds for 3 returns 6 posts with degraded_count 3."
              example: { posts: ["Idea one.", "Idea two.", "Idea three."], degraded_count: 0 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts/generate_thread:
    post:
      tags: [AI Generation]
      operationId: generateThread
      summary: Generate a thread
      description: |
        Requires scope: `ai:generate` and a paid plan. Generates a connected
        thread of posts. Shares a 6/min rate-limit bucket with the batch and
        carousel generators.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                topic: { type: string }
                tweet_count: { type: integer, minimum: 2, maximum: 10, default: 5 }
                platform: { $ref: "#/components/schemas/Platform" }
            example: { topic: "how to write a good cold email", tweet_count: 5, platform: "twitter" }
      responses:
        "200":
          description: The generated thread, one entry per post.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tweets:
                    type: array
                    items: { type: string }
              example: { tweets: ["1/ Cold emails fail for one reason.", "2/ You lead with yourself."] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts/generate_carousel:
    post:
      tags: [AI Generation]
      operationId: generateCarousel
      summary: Generate a LinkedIn carousel
      description: |
        Requires scope: `ai:generate` and a paid plan. Generates a slide-based
        carousel document for LinkedIn. A connected LinkedIn account is
        required. Shares a 6/min rate-limit bucket with the batch and thread
        generators.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                topic: { type: string }
                slide_count: { type: integer, minimum: 2, maximum: 12, default: 6 }
                theme: { type: string, enum: [dark, light, blue, green], default: dark }
                cta_text: { type: string }
                social_account_id: { type: integer, description: "LinkedIn account to target. Defaults to your first connected LinkedIn account." }
            example: { topic: "5 signs your onboarding is broken", slide_count: 6, theme: "dark", cta_text: "Follow for more" }
      responses:
        "200":
          description: The generated carousel.
          content:
            application/json:
              schema:
                type: object
                properties:
                  slides:
                    type: array
                    items:
                      type: object
                      properties:
                        title: { type: string }
                        body: { type: string }
                  document_urn: { type: string }
                  title: { type: string }
                  caption: { type: string }
                  pdf_preview: { type: string, description: "Base64-encoded PDF preview." }
              example:
                slides: [{ title: "Slide 1", body: "Your users churn in week one." }]
                document_urn: "urn:li:document:abc123"
                title: "5 signs your onboarding is broken"
                caption: "Most onboarding dies in the first session."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts/{id}:
    parameters:
      - $ref: "#/components/parameters/PostId"
    get:
      tags: [Posts]
      operationId: getPost
      summary: Get a post
      description: "Requires scope: `posts:read`."
      responses:
        "200":
          description: The post.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Posts]
      operationId: updatePost
      summary: Update a post
      description: "Requires scope: `posts:write`. A post that is processing or already posted cannot be edited."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PostInput" }
            example:
              post_contents:
                - platform: "twitter"
                  body: "Updated copy for this post."
              scheduled_at: "2026-07-15T16:30:00Z"
      responses:
        "200":
          description: The updated post.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Posts]
      operationId: deletePost
      summary: Delete a post
      description: "Requires scope: `posts:write`."
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posts/{id}/submit:
    parameters:
      - $ref: "#/components/parameters/PostId"
    post:
      tags: [Posts]
      operationId: submitPost
      summary: Submit a post for review
      description: "Requires scope: `posts:write` and a write role. Moves a draft into the review queue."
      responses:
        "200":
          description: The submitted post.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posts/{id}/approve:
    parameters:
      - $ref: "#/components/parameters/PostId"
    post:
      tags: [Posts]
      operationId: approvePost
      summary: Approve a post
      description: "Requires scope: `posts:write` and a Pro/Team plan. Approves a post awaiting review."
      responses:
        "200":
          description: The approved post.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posts/{id}/reject:
    parameters:
      - $ref: "#/components/parameters/PostId"
    post:
      tags: [Posts]
      operationId: rejectPost
      summary: Reject a post
      description: "Requires scope: `posts:write` and a Pro/Team plan. Rejects a post awaiting review, optionally with a reason."
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                rejection_reason: { type: string }
            example: { rejection_reason: "Off-brand — rework the hook." }
      responses:
        "200":
          description: The rejected post.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posts/{post_id}/publishes:
    parameters:
      - $ref: "#/components/parameters/PublishPostId"
    post:
      tags: [Publishing]
      operationId: publishPost
      summary: Publish a post
      description: |
        Requires scope: `publish:write`. Publishes an existing post. Without
        `scheduled_at` or `use_next_slot` the post is sent immediately. Pass
        `scheduled_at` to schedule it, or `use_next_slot: true` to queue it into
        the next open posting-schedule slot. Target accounts default to those
        already attached to the post; override with `social_account_id(s)`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                social_account_id: { type: integer }
                social_account_ids:
                  type: array
                  items: { type: integer }
                scheduled_at: { type: string, format: date-time, description: "ISO 8601 UTC. Must be more than 30 seconds in the future." }
                use_next_slot: { type: boolean, description: "Queue into the next open schedule slot instead of publishing now." }
            example: { social_account_ids: [301], use_next_slot: true }
      responses:
        "200":
          description: The post after publishing or scheduling.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /posts/{post_id}/publishes/next_slot:
    parameters:
      - $ref: "#/components/parameters/PublishPostId"
    get:
      tags: [Publishing]
      operationId: getNextSlot
      summary: Get the next open posting slot
      description: "Requires scope: `posts:read`. Returns the earliest open schedule slot for the post's (or supplied) accounts."
      parameters:
        - name: social_account_id
          in: query
          schema: { type: integer }
        - name: social_account_ids
          in: query
          schema: { type: array, items: { type: integer } }
      responses:
        "200":
          description: The next available slot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  scheduled_at: { type: string, format: date-time }
              example: { scheduled_at: "2026-07-14T15:00:00Z" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /media/uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadMedia
      summary: Upload media (Facebook, Instagram, Pinterest, TikTok, Threads, Discord, Slack, Telegram, Tumblr, X, Mastodon, Bluesky)
      description: |
        Requires scope: `publish:write`. Uploads base64-encoded media to storage
        and returns an `r2_key` to reference from a post's `post_contents`
        metadata. `platform` selects the size/type limits. For X, Mastodon, and
        Bluesky the image is minted into the platform's native handle at publish
        time under each targeted account, so the same `r2_key` works correctly
        even when a post targets more than one account on that platform.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                platform:
                  type: string
                  enum: [facebook, instagram, pinterest, tiktok, threads, discord, slack, telegram, tumblr, twitter, mastodon, bluesky]
                content_type: { type: string, example: "image/jpeg" }
                media: { type: string, description: "Base64-encoded file bytes." }
              required: [platform, content_type, media]
            example: { platform: "instagram", content_type: "image/jpeg", media: "<base64>" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
              example: { url: "https://media.xreplyai.com/instagram/4021/uuid.jpg", r2_key: "instagram/4021/uuid.jpg" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deleteMedia
      summary: Delete an uploaded media file
      description: "Requires scope: `publish:write`. Deletes a previously uploaded file by its `r2_key`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                platform: { type: string }
                r2_key: { type: string }
              required: [platform, r2_key]
            example: { platform: "instagram", r2_key: "instagram/4021/uuid.jpg" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /twitter/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadTwitterMedia
      summary: Upload an X (Twitter) image
      description: "Requires scope: `publish:write`. Uploads a base64 image to X and returns a `media_id` for use in a post's metadata. X media IDs are scoped to the uploading account — pass `social_account_id` when the user has more than one X account connected, or the media is minted under the oldest connected account and is invalid for the others."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Base64ImageUpload"
                - type: object
                  properties:
                    social_account_id:
                      type: integer
                      description: "Optional. The connected X account to mint the media under. Must belong to the authenticated user (404 otherwise). Defaults to the oldest connected X account."
            example: { content_type: "image/png", image: "<base64>", social_account_id: 27 }
      responses:
        "200":
          description: The uploaded media reference.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media_id: { type: string }
              example: { media_id: "1789456123456789012" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /bluesky/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadBlueskyMedia
      summary: Upload a Bluesky image
      description: "Requires scope: `publish:write`. Uploads a base64 image and returns a blob reference for use in a post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Base64ImageUpload" }
      responses:
        "200":
          description: The uploaded blob reference.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /mastodon/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadMastodonMedia
      summary: Upload a Mastodon image
      description: "Requires scope: `publish:write`. Uploads a base64 image and returns a media reference for use in a post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Base64ImageUpload" }
      responses:
        "200":
          description: The uploaded media reference.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /linkedin/image_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadLinkedinImage
      summary: Upload a LinkedIn image
      description: "Requires scope: `publish:write`. Uploads a base64 image and returns an `asset_urn` for use in a post's metadata. Asset URNs are scoped to the uploading account — pass `social_account_id` when the user has more than one LinkedIn account connected."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Base64ImageUpload"
                - type: object
                  properties:
                    social_account_id:
                      type: integer
                      description: "Optional. The connected LinkedIn account to mint the asset under. Must belong to the authenticated user (404 otherwise). Defaults to the oldest connected LinkedIn account."
      responses:
        "200":
          description: The uploaded asset.
          content:
            application/json:
              schema:
                type: object
                properties:
                  asset_urn: { type: string }
              example: { asset_urn: "urn:li:image:abc123" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /linkedin/document_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadLinkedinDocument
      summary: Upload a LinkedIn document
      description: "Requires scope: `publish:write`. Uploads a base64 document and returns a `document_urn` for use in a carousel post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content_type: { type: string, example: "application/pdf" }
                document: { type: string, description: "Base64-encoded document bytes." }
                social_account_id: { type: integer, description: "Optional. The connected LinkedIn account to mint the document under. Must belong to the authenticated user (404 otherwise). Defaults to the oldest connected LinkedIn account." }
              required: [content_type, document]
      responses:
        "200":
          description: The uploaded document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  document_urn: { type: string }
              example: { document_urn: "urn:li:document:abc123" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /linkedin/video_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadLinkedinVideo
      summary: Upload a LinkedIn video
      description: |
        Requires scope: `publish:write`. Uploads a video as `multipart/form-data`
        (field `video`, max 100 MB, `video/mp4`) and returns an `asset_urn`.
        Rate-limited to 10 per hour.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                video: { type: string, format: binary }
                social_account_id: { type: integer, description: "Optional. The connected LinkedIn account to mint the video asset under. Must belong to the authenticated user (404 otherwise). Defaults to the oldest connected LinkedIn account." }
              required: [video]
      responses:
        "200":
          description: The uploaded video asset.
          content:
            application/json:
              schema:
                type: object
                properties:
                  asset_urn: { type: string }
              example: { asset_urn: "urn:li:video:abc123" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /linkedin/uploads/presign:
    get:
      tags: [Media Uploads]
      operationId: presignLinkedinUpload
      summary: Presign a LinkedIn video upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT a video (max 100 MB, `video/mp4`) directly to storage, plus the `r2_key` to reference afterward."
      parameters:
        - $ref: "#/components/parameters/PresignContentType"
        - $ref: "#/components/parameters/PresignFileSize"
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
              example: { upload_url: "https://r2.example.com/put?sig=...", r2_key: "linkedin/4021/uuid.mp4" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /pinterest/uploads/presign:
    get:
      tags: [Media Uploads]
      operationId: presignPinterestUpload
      summary: Presign a Pinterest media upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT media directly to storage, plus the `r2_key`."
      parameters:
        - $ref: "#/components/parameters/PresignContentType"
        - $ref: "#/components/parameters/PresignFileSize"
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /tiktok/creator_info:
    get:
      tags: [Social Accounts]
      operationId: getTiktokCreatorInfo
      summary: Fetch TikTok creator info for a connected account
      description: "Requires scope: `accounts:read`. Returns the creator's display details and posting capabilities from TikTok, used to populate the composer before a Direct Post. TikTok's Content Sharing Guidelines require the upload UI to name the creator being posted to and to disable any interaction the creator has turned off, so `comment_disabled`, `duet_disabled`, and `stitch_disabled` are creator-level settings that override any per-post choice."
      parameters:
        - name: social_account_id
          in: query
          required: true
          description: ID of the connected TikTok social account.
          schema: { type: integer }
      responses:
        "200":
          description: The creator's TikTok posting details.
          content:
            application/json:
              schema:
                type: object
                properties:
                  nickname: { type: string, nullable: true, description: "The creator's TikTok display name." }
                  username: { type: string, nullable: true }
                  avatar_url: { type: string, nullable: true }
                  privacy_level_options:
                    type: array
                    nullable: true
                    description: Visibility levels this account is permitted to use.
                    items: { type: string, enum: [PUBLIC_TO_EVERYONE, FOLLOWER_OF_CREATOR, MUTUAL_FOLLOW_FRIENDS, SELF_ONLY] }
                  comment_disabled: { type: boolean, nullable: true }
                  duet_disabled: { type: boolean, nullable: true }
                  stitch_disabled: { type: boolean, nullable: true }
                  max_video_post_duration_sec: { type: integer, nullable: true }
              example:
                nickname: Jane Doe
                username: janedoe
                avatar_url: "https://p16.tiktokcdn.com/avatar.jpeg"
                privacy_level_options: [PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, SELF_ONLY]
                comment_disabled: false
                duet_disabled: true
                stitch_disabled: false
                max_video_post_duration_sec: 600
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /tiktok/uploads/presign:
    get:
      tags: [Media Uploads]
      operationId: presignTiktokUpload
      summary: Presign a TikTok media upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT media directly to storage, plus the `r2_key`."
      parameters:
        - $ref: "#/components/parameters/PresignContentType"
        - $ref: "#/components/parameters/PresignFileSize"
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /twitter/uploads/presign:
    get:
      tags: [Media Uploads]
      operationId: presignTwitterUpload
      summary: Presign an X (Twitter) video upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT a video (max 500 MB, `video/mp4`) directly to storage, plus the `r2_key` to reference afterward. At publish time the video is uploaded to X via the v2 chunked flow (`media_category=tweet_video`); X enforces a 140-second duration cap."
      parameters:
        - $ref: "#/components/parameters/PresignContentType"
        - $ref: "#/components/parameters/PresignFileSize"
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
              example: { upload_url: "https://r2.example.com/put?sig=...", r2_key: "twitter/4021/uuid.mp4" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /youtube/uploads/presign:
    post:
      tags: [Media Uploads]
      operationId: presignYoutubeUpload
      summary: Presign a YouTube video upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT a video (max 4 GB) directly to storage, plus the `r2_key`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content_type: { type: string, example: "video/mp4" }
                file_size: { type: integer, description: "File size in bytes. Max 4 GB." }
              required: [content_type, file_size]
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /tumblr/uploads/presign:
    get:
      tags: [Media Uploads]
      operationId: presignTumblrUpload
      summary: Presign a Tumblr media upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT media directly to storage, plus the `r2_key`."
      parameters:
        - $ref: "#/components/parameters/PresignContentType"
        - $ref: "#/components/parameters/PresignFileSize"
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /instagram/uploads/presign:
    get:
      tags: [Media Uploads]
      operationId: presignInstagramUpload
      summary: Presign an Instagram media upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT media directly to storage, plus the `r2_key`."
      parameters:
        - $ref: "#/components/parameters/PresignContentType"
        - $ref: "#/components/parameters/PresignFileSize"
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /facebook/uploads/presign:
    get:
      tags: [Media Uploads]
      operationId: presignFacebookUpload
      summary: Presign a Facebook video upload
      description: "Requires scope: `publish:write`. Returns a presigned URL to PUT a video (up to 1 GB) directly to storage, plus the `r2_key`. Facebook presign accepts video content types only; images go through POST /facebook/media_uploads."
      parameters:
        - $ref: "#/components/parameters/PresignContentType"
        - $ref: "#/components/parameters/PresignFileSize"
      responses:
        "200":
          description: A presigned upload target.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PresignResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /facebook/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadFacebookMedia
      summary: Upload Facebook media
      description: "Requires scope: `publish:write`. Uploads a base64 image (JPEG/PNG) and returns an `r2_key` to reference from a post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
            example: { content_type: "image/jpeg", media: "<base64>" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
              example: { url: "https://media.xreplyai.com/facebook/4021/uuid.jpg", r2_key: "facebook/4021/uuid.jpg" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /instagram/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadInstagramMedia
      summary: Upload Instagram media
      description: "Requires scope: `publish:write`. Uploads base64 media (image or video) and returns an `r2_key` to reference from a post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deleteInstagramMedia
      summary: Delete an uploaded Instagram media file
      description: "Requires scope: `publish:write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaDeleteInput" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /pinterest/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadPinterestMedia
      summary: Upload Pinterest media
      description: "Requires scope: `publish:write`. Uploads a base64 image and returns an `r2_key` to reference from a post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deletePinterestMedia
      summary: Delete an uploaded Pinterest media file
      description: "Requires scope: `publish:write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaDeleteInput" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /tiktok/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadTiktokMedia
      summary: Upload TikTok media
      description: "Requires scope: `publish:write`. Uploads a base64 image and returns an `r2_key` to reference from a post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deleteTiktokMedia
      summary: Delete an uploaded TikTok media file
      description: "Requires scope: `publish:write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaDeleteInput" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /threads/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadThreadsMedia
      summary: Upload Threads media
      description: "Requires scope: `publish:write`. Uploads a base64 image and returns an `r2_key` to reference from a post's metadata."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /discord/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadDiscordMedia
      summary: Upload Discord media
      description: "Requires scope: `publish:write`. Uploads base64 media and returns an `r2_key`. A connected Discord account is required."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deleteDiscordMedia
      summary: Delete an uploaded Discord media file
      description: "Requires scope: `publish:write`. Returns `403` if the file is not owned by the caller."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaDeleteInput" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /slack/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadSlackMedia
      summary: Upload Slack media
      description: "Requires scope: `publish:write`. Uploads base64 media and returns an `r2_key`. A connected Slack account is required."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deleteSlackMedia
      summary: Delete an uploaded Slack media file
      description: "Requires scope: `publish:write`. Returns `403` if the file is not owned by the caller."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaDeleteInput" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /telegram/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadTelegramMedia
      summary: Upload Telegram media
      description: "Requires scope: `publish:write`. Uploads base64 media and returns an `r2_key`. A connected Telegram account is required."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deleteTelegramMedia
      summary: Delete an uploaded Telegram media file
      description: "Requires scope: `publish:write`. Returns `403` if the file is not owned by the caller."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaDeleteInput" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /tumblr/media_uploads:
    post:
      tags: [Media Uploads]
      operationId: uploadTumblrMedia
      summary: Upload Tumblr media
      description: "Requires scope: `publish:write`. Uploads base64 media and returns an `r2_key`. A connected Tumblr account is required."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformMediaUploadInput" }
      responses:
        "200":
          description: The stored media reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Media Uploads]
      operationId: deleteTumblrMedia
      summary: Delete an uploaded Tumblr media file
      description: "Requires scope: `publish:write`. Returns `403` if the file is not owned by the caller."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaDeleteInput" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /short_links:
    post:
      tags: [Posts]
      operationId: createShortLinks
      summary: Mint branded short links
      description: |
        Requires scope `links:write`. Mints a branded short link for each supplied
        URL and returns a mapping of original URL to short link. Links are keyed
        per user, so the same URL shortened twice returns the same link and does
        not consume additional quota.

        UTM handling: any utm_* params already on the URL are preserved and win;
        defaults (`utm_source=social`, `utm_medium=social`, a month-bucketed
        `utm_campaign`) only fill the ones the caller did not set.

        A URL that is already a short link, or that resolves to a private
        network address, is skipped. A URL that fails to mint is omitted from
        `links` rather than failing the whole request.

        Note posts created via `POST /posts` already shorten their links
        automatically — this endpoint is for shortening without posting.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [urls]
              properties:
                urls:
                  type: array
                  maxItems: 5
                  items: { type: string }
                  description: "Up to 5 URLs per request."
      responses:
        "200":
          description: Mapping of each original URL to its short link.
          content:
            application/json:
              schema:
                type: object
                properties:
                  links:
                    type: object
                    additionalProperties: { type: string }
        "422":
          description: "urls was missing, empty, or contained more than 5 entries."
        "503":
          description: "Link shortening is not configured on the server."

  /platforms:
    get:
      tags: [Social Accounts]
      operationId: listPlatforms
      summary: List per-platform capabilities
      description: |
        Requires scope: `posts:read`. Returns machine-readable per-platform
        capabilities (character limits, supported content types, image counts,
        and media content-type + size limits) so clients don't hardcode them.
        `content_types` is per-platform truth — only the types the platform can
        actually publish (e.g. `document` appears only for linkedin, `poll` only
        for twitter and linkedin, and media-required platforms omit `text`).
        Fields that don't apply to a platform (`max_images`, `media`) are omitted.
      responses:
        "200":
          description: Capabilities for every supported platform.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platforms:
                    type: array
                    items: { $ref: "#/components/schemas/PlatformCapability" }
              example:
                platforms:
                  - platform: "twitter"
                    char_limit: 280
                    content_types: [text, single_image, multi_image, video, poll]
                    max_images: 4
                    media:
                      allowed_content_types: [image/jpeg, image/png, image/gif, image/webp]
                      size_limits:
                        "image/gif": { bytes: 15728640, label: "15 MB" }
                        default: { bytes: 5242880, label: "5 MB" }
                  - platform: "linkedin"
                    char_limit: 3000
                    content_types: [text, single_image, multi_image, video, document, poll]
                    max_images: 20
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /social_accounts:
    get:
      tags: [Social Accounts]
      operationId: listSocialAccounts
      summary: List connected social accounts
      description: "Requires scope: `accounts:read`. Connecting and disconnecting accounts is dashboard-only."
      responses:
        "200":
          description: Connected accounts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  social_accounts:
                    type: array
                    items: { $ref: "#/components/schemas/SocialAccount" }
              example:
                social_accounts:
                  - id: 301
                    platform: "twitter"
                    uid: "1456789012"
                    username: "founder"
                    connected_at: "2026-02-01T10:00:00Z"
                    needs_reconnect: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /twitter/communities:
    get:
      tags: [Social Accounts]
      operationId: listTwitterCommunities
      summary: List saved X communities
      description: "Requires scope: `communities:read`. Returns the X communities you have posted to or saved."
      responses:
        "200":
          description: Saved communities.
          content:
            application/json:
              schema:
                type: object
                properties:
                  communities:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        community_id: { type: string }
                        name: { type: string }
                        last_used_at: { type: [string, "null"], format: date-time }
              example:
                communities:
                  - id: 12
                    community_id: "1699887766554433221"
                    name: "Build in Public"
                    last_used_at: "2026-07-01T12:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instagram/audio/search:
    get:
      tags: [Social Accounts]
      operationId: searchInstagramAudio
      summary: Search Instagram audio
      description: "Requires scope: `accounts:read`. Searches Instagram audio for reels. A connected Instagram account is required."
      parameters:
        - name: search_query
          in: query
          required: true
          schema: { type: string }
        - name: audio_type
          in: query
          schema: { type: string, enum: [original_sound, music], default: music }
        - name: social_account_id
          in: query
          schema: { type: integer }
      responses:
        "200":
          description: Matching audio tracks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  audio:
                    type: array
                    items:
                      type: object
                      properties:
                        audio_id: { type: string }
                        title: { type: string }
                        ig_artist: { type: string }
              example:
                audio:
                  - audio_id: "17900000000000000"
                    title: "Lofi Beat"
                    ig_artist: "Producer X"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /pinterest/boards:
    get:
      tags: [Social Accounts]
      operationId: listPinterestBoards
      summary: List Pinterest boards
      description: "Requires scope: `accounts:read`. Returns the boards on your connected Pinterest account."
      responses:
        "200":
          description: Pinterest boards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  boards:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        privacy: { type: string }
                  cached_at: { type: [string, "null"], format: date-time }
              example:
                boards:
                  - id: "998877665544"
                    name: "Founder Notes"
                    privacy: "PUBLIC"
                cached_at: "2026-07-13T08:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /analytics:
    get:
      tags: [Analytics]
      operationId: getAnalytics
      summary: Get analytics rollups
      description: "Requires scope: `analytics:read`. Returns per-account rollups with period-over-period comparison. The date window selects posts by publish time (falling back to scheduled time, then last-modified, when a post has never been published)."
      parameters:
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - name: social_account_id
          in: query
          schema: { type: integer }
        - name: platform
          in: query
          schema: { $ref: "#/components/schemas/Platform" }
      responses:
        "200":
          description: Analytics rollups.
          content:
            application/json:
              schema:
                type: object
                properties:
                  comparison:
                    type: object
                    properties:
                      start_date: { type: string }
                      end_date: { type: string }
                      prior_start_date: { type: string }
                      prior_end_date: { type: string }
                  followers_gained: { type: integer }
                  supported_platforms:
                    type: array
                    items: { type: string }
                    description: Platforms that report post-level metrics (impressions, engagement).
                  platform_capabilities:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        post_metrics: { type: boolean }
                        followers: { type: boolean }
                    description: "Per-platform capability map (keyed by platform) used to gate analytics board sections client-side: whether that platform reports post metrics and/or follower counts."
                  rollups:
                    type: array
                    items: { $ref: "#/components/schemas/AnalyticsRollup" }
              example:
                comparison:
                  start_date: "2026-07-01"
                  end_date: "2026-07-13"
                  prior_start_date: "2026-06-18"
                  prior_end_date: "2026-06-30"
                followers_gained: 42
                supported_platforms: ["twitter", "linkedin", "instagram"]
                platform_capabilities:
                  twitter: { post_metrics: true, followers: true }
                  tumblr: { post_metrics: false, followers: true }
                rollups:
                  - social_account_id: 301
                    platform: "twitter"
                    total_posts_with_metrics: 18
                    total_impressions: 54210
                    avg_engagement_rate: 3.4
                    prior_total_impressions: 48200
                    prior_total_posts_with_metrics: 15
                    pct_change_impressions: 12.5
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /analytics/timeseries:
    get:
      tags: [Analytics]
      operationId: getAnalyticsTimeseries
      summary: Get analytics timeseries
      description: "Requires scope: `analytics:read`. Returns a current series and a prior-period comparison series."
      parameters:
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - name: social_account_id
          in: query
          schema: { type: integer }
        - name: platform
          in: query
          description: Restrict both series to accounts on a single platform. Aggregates across every account on that platform; composes with social_account_id.
          schema: { $ref: "#/components/schemas/Platform" }
      responses:
        "200":
          description: Current and comparison series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  current:
                    type: array
                    items: { $ref: "#/components/schemas/TimeseriesPoint" }
                  comparison:
                    type: array
                    items: { $ref: "#/components/schemas/TimeseriesPoint" }
              example:
                current:
                  - date: "2026-07-12"
                    impressions: 4210
                    engagement_rate: 3.1
                comparison:
                  - date: "2026-06-29"
                    impressions: 3800
                    engagement_rate: 2.7
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /analytics/best_times:
    get:
      tags: [Analytics]
      operationId: getBestTimes
      summary: Get best posting times
      description: "Requires scope: `analytics:read`. Returns day-of-week × hour cells ranked by engagement, plus the single best cell."
      parameters:
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - name: social_account_id
          in: query
          schema: { type: integer }
        - name: platform
          in: query
          schema: { $ref: "#/components/schemas/Platform" }
      responses:
        "200":
          description: Engagement heatmap cells.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cells:
                    type: array
                    items: { $ref: "#/components/schemas/BestTimeCell" }
                  best: { $ref: "#/components/schemas/BestTimeCell" }
              example:
                cells:
                  - day_of_week: 2
                    hour: 15
                    avg_engagement_rate: 4.2
                    post_count: 6
                best:
                  day_of_week: 2
                  hour: 15
                  avg_engagement_rate: 4.2
                  post_count: 6
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /preferences:
    get:
      tags: [Preferences & Styles]
      operationId: getPreferences
      summary: Get generation preferences
      description: "Requires scope: `preferences:read`."
      responses:
        "200":
          description: The current preferences.
          content:
            application/json:
              schema:
                type: object
                properties:
                  preferences: { $ref: "#/components/schemas/Preferences" }
              example:
                preferences:
                  tone: "conversational"
                  include_emoji: false
                  structure: "short"
                  timezone: "America/New_York"
                  use_post_metrics_for_ai: true
                  theme: "system"
                  ai_model: "claude-opus"
                  available_ai_models:
                    - id: "gemini-flash"
                      label: "Gemini Flash"
                      premium: false
                      typical_cost_micros: 0
                    - id: "deepseek-v4"
                      label: "DeepSeek V4"
                      premium: true
                      typical_cost_micros: 1000
                    - id: "gpt-5"
                      label: "GPT-5"
                      premium: true
                      typical_cost_micros: 7000
                    - id: "grok-4"
                      label: "Grok 4.5"
                      premium: true
                      typical_cost_micros: 4000
                    - id: "gemini-pro"
                      label: "Gemini 3 Pro"
                      premium: true
                      typical_cost_micros: 9000
                    - id: "claude-opus"
                      label: "Claude Opus 5"
                      premium: true
                      typical_cost_micros: 18000
                    - id: "claude-fable"
                      label: "Claude Fable 5"
                      premium: true
                      typical_cost_micros: 35000
                  email_notifications:
                    post_failure: true
                    weekly_briefing: true
                    product_tips: false
                    re_engagement: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      tags: [Preferences & Styles]
      operationId: updatePreferences
      summary: Update generation preferences
      description: "Requires scope: `preferences:write`. Merges the supplied fields into existing preferences."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                preferences: { $ref: "#/components/schemas/PreferencesInput" }
              required: [preferences]
            example:
              preferences:
                tone: "punchy"
                include_emoji: true
                timezone: "Europe/London"
                ai_model: "claude-opus"
      responses:
        "200":
          description: The updated preferences.
          content:
            application/json:
              schema:
                type: object
                properties:
                  preferences: { $ref: "#/components/schemas/Preferences" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /platform_styles:
    get:
      tags: [Preferences & Styles]
      operationId: listPlatformStyles
      summary: List per-platform style profiles
      description: "Requires scope: `platform_styles:read`. Returns the effective style profile for each platform (defaults merged with your overrides)."
      responses:
        "200":
          description: Effective style profiles keyed by platform.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform_styles:
                    type: object
                    additionalProperties: { $ref: "#/components/schemas/PlatformStyle" }
              example:
                platform_styles:
                  twitter:
                    tone: "conversational"
                    structure: "short"
                    target_length: 200
                    max_length: 280
                    is_default: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /platform_styles/{platform}:
    parameters:
      - name: platform
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Platform" }
    patch:
      tags: [Preferences & Styles]
      operationId: updatePlatformStyle
      summary: Update a platform style profile
      description: "Requires scope: `platform_styles:write`. Merges the supplied overrides for one platform."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                platform_style: { $ref: "#/components/schemas/PlatformStyleInput" }
              required: [platform_style]
            example:
              platform_style:
                tone: "punchy"
                target_length: 180
                custom_instructions: "Never use hashtags."
      responses:
        "200":
          description: The updated effective style.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform_style: { $ref: "#/components/schemas/PlatformStyle" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Preferences & Styles]
      operationId: resetPlatformStyle
      summary: Reset a platform style profile
      description: "Requires scope: `platform_styles:write`. Clears your overrides for one platform, reverting to defaults."
      responses:
        "204": { description: Reset. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /custom_rules:
    get:
      tags: [Rules]
      operationId: listCustomRules
      summary: List custom rules
      description: "Requires scope: `rules:read`. Reads are available on every plan so users can always see rules that steer their generation; writes require a paid plan."
      responses:
        "200":
          description: Your custom rules.
          content:
            application/json:
              schema:
                type: object
                properties:
                  custom_rules:
                    type: array
                    items: { $ref: "#/components/schemas/CustomRule" }
              example:
                custom_rules:
                  - id: 71
                    title: "No hashtags"
                    content: "Never include hashtags in any reply."
                    active: true
                    scope: reply
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Rules]
      operationId: createCustomRule
      summary: Create a custom rule
      description: "Requires scope: `rules:write` and a paid plan."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                custom_rule: { $ref: "#/components/schemas/CustomRuleInput" }
              required: [custom_rule]
            example:
              custom_rule:
                title: "No hashtags"
                content: "Never include hashtags in any post."
                active: true
      responses:
        "201":
          description: The created rule.
          content:
            application/json:
              schema:
                type: object
                properties:
                  custom_rule: { $ref: "#/components/schemas/CustomRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /custom_rules/{id}:
    parameters:
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getCustomRule
      summary: Get a custom rule
      description: "Requires scope: `rules:read`. Available on every plan."
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema:
                type: object
                properties:
                  custom_rule: { $ref: "#/components/schemas/CustomRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Rules]
      operationId: updateCustomRule
      summary: Update a custom rule
      description: "Requires scope: `rules:write` and a paid plan."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                custom_rule: { $ref: "#/components/schemas/CustomRuleInput" }
              required: [custom_rule]
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema:
                type: object
                properties:
                  custom_rule: { $ref: "#/components/schemas/CustomRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
    delete:
      tags: [Rules]
      operationId: deleteCustomRule
      summary: Delete a custom rule
      description: "Requires scope: `rules:write` and a paid plan."
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /custom_rules/{id}/toggle:
    parameters:
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleCustomRule
      summary: Toggle a custom rule on or off
      description: "Requires scope: `rules:write` and a paid plan. Flips the rule's `active` flag."
      responses:
        "200":
          description: The toggled rule.
          content:
            application/json:
              schema:
                type: object
                properties:
                  custom_rule: { $ref: "#/components/schemas/CustomRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posting_schedules:
    get:
      tags: [Schedules & Plans]
      operationId: listPostingSchedules
      summary: List posting schedules
      description: "Requires scope: `schedules:read`. Returns each connected account's posting schedule and its slots."
      responses:
        "200":
          description: Posting schedules.
          content:
            application/json:
              schema:
                type: object
                properties:
                  posting_schedules:
                    type: array
                    items: { $ref: "#/components/schemas/PostingSchedule" }
              example:
                posting_schedules:
                  - id: 55
                    social_account_id: 301
                    timezone: "America/New_York"
                    max_posts_per_day: 3
                    paused: false
                    posting_slots:
                      - id: 900
                        day_of_week: 1
                        time_of_day: "09:00"
                        enabled: true
                        source: "manual"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /posting_schedules/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    patch:
      tags: [Schedules & Plans]
      operationId: updatePostingSchedule
      summary: Update a posting schedule
      description: "Requires scope: `schedules:write` and a write role. Updates schedule settings and/or its slots."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                posting_schedule: { $ref: "#/components/schemas/PostingScheduleInput" }
              required: [posting_schedule]
            example:
              posting_schedule:
                max_posts_per_day: 2
                paused: true
      responses:
        "200":
          description: The updated schedule.
          content:
            application/json:
              schema:
                type: object
                properties:
                  posting_schedule: { $ref: "#/components/schemas/PostingSchedule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /content_plans:
    get:
      tags: [Schedules & Plans]
      operationId: listContentPlans
      summary: List content plans
      description: "Requires scope: `plans:read`. Returns your recurring content plans."
      responses:
        "200":
          description: Content plans.
          content:
            application/json:
              schema:
                type: object
                properties:
                  content_plans:
                    type: array
                    items: { $ref: "#/components/schemas/ContentPlan" }
              example:
                content_plans:
                  - id: 12
                    name: "Weekly build-in-public"
                    active: true
                    schedule_day: "monday"
                    schedule_time: "09:00"
                    next_run_at: "2026-07-14T13:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Schedules & Plans]
      operationId: createContentPlan
      summary: Create a content plan
      description: "Requires scope: `plans:write`. Creates a recurring content plan."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content_plan:
                  type: object
                  properties:
                    name: { type: string }
                    active: { type: boolean }
                    schedule_day: { type: integer }
                    schedule_time: { type: string }
                    platform_configs: { type: object }
                    channels:
                      type: array
                      description: "Account-grained configuration, one entry per connected account. Accepts two accounts on the same platform, which platform_configs cannot express. Takes precedence over platform_configs when both are sent."
                      items:
                        type: object
                        properties:
                          social_account_id: { type: integer }
                          platform: { type: string }
                          format: { type: [string, "null"] }
                          board_id: { type: [string, "null"] }
                          angles: { type: array, items: { type: string } }
                          posts_per_day: { type: [integer, "null"] }
                          quote_topic: { type: [string, "null"] }
                          watermark_enabled: { type: boolean }
                          watermark_text: { type: [string, "null"] }
                          watermark_every_n: { type: [integer, "null"] }
                          carousel_every_n: { type: [integer, "null"] }
                          active: { type: boolean }
                    content_strategy: { type: object }
            example: { content_plan: { name: "Weekly build-in-public", schedule_day: 1, schedule_time: "09:00" } }
      responses:
        "201":
          description: The created content plan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  content_plan: { $ref: "#/components/schemas/ContentPlan" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /content_plans/{id}/run:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Schedules & Plans]
      operationId: runContentPlan
      summary: Run a content plan now
      description: "Requires scope: `plans:write`. Triggers an immediate run of the plan's generation."
      responses:
        "200":
          description: The plan after the run was triggered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  content_plan: { $ref: "#/components/schemas/ContentPlan" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /posting_schedules/open_slots:
    get:
      tags: [Schedules & Plans]
      operationId: listOpenSlots
      summary: List upcoming open posting slots
      description: "Requires scope: `schedules:read`. Returns upcoming schedule slots not yet filled by a post, across your connected accounts."
      responses:
        "200":
          description: Upcoming open slots.
          content:
            application/json:
              schema:
                type: object
                properties:
                  open_slots:
                    type: array
                    items:
                      type: object
                      properties:
                        scheduled_at: { type: string, format: date-time }
                        social_account_id: { type: integer }
                        platform: { $ref: "#/components/schemas/Platform" }
              example:
                open_slots:
                  - scheduled_at: "2026-07-23T13:00:00Z"
                    social_account_id: 301
                    platform: "twitter"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /replies/generate:
    post:
      tags: [AI Generation]
      operationId: generateReply
      summary: Generate a reply to a post in your voice
      description: "Requires scope: `replies:generate` and a paid plan. Subject to your plan's daily generation quota."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tweet:
                  type: object
                  properties:
                    text: { type: string }
                    author: { type: string }
                previous_attempts:
                  type: array
                  items: { type: string }
                  description: "Up to 5 earlier drafts for the same post that the model should avoid repeating. Pass these when regenerating to get a different take."
                attempt_number:
                  type: integer
                  description: "Which attempt this is for the same post. 0 (default) for the first generation, incrementing on each regeneration. Used for retry analytics."
            example: { tweet: { text: "Shipping beats planning.", author: "somefounder" } }
      responses:
        "200":
          description: The generated reply.
          content:
            application/json:
              schema:
                type: object
                properties:
                  generation_id: { type: integer }
                  reply:
                    type: object
                    properties:
                      text: { type: string }
                  context: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /content_ideas:
    get:
      tags: [Schedules & Plans]
      operationId: listContentIdeas
      summary: List content ideas
      description: "Requires scope: `content_ideas:read`. Returns current content ideas from the planner."
      responses:
        "200":
          description: Content ideas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  content_ideas:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        platform: { $ref: "#/components/schemas/Platform" }
                        topic: { type: string }
                        angle: { type: string }
                        prompt: { type: string }
                        source: { type: string }
                        source_url: { type: [string, "null"] }
                        source_label: { type: [string, "null"] }
                        generated_for_date: { type: string, format: date }
                        dismissed_at: { type: [string, "null"], format: date-time }
                        used_at: { type: [string, "null"], format: date-time }
                        media_type: { type: [string, "null"] }
                        shared_inspiration_item_id: { type: [integer, "null"] }
                  meta:
                    type: object
                    properties:
                      limit: { type: integer }
                      total_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /content_ideas/refresh:
    post:
      tags: [Schedules & Plans]
      operationId: refreshContentIdeas
      summary: Refresh content ideas
      description: "Requires scope: `content_ideas:write`. Queues a background refresh of the idea list. Returns 202 with an empty body."
      responses:
        "202":
          description: Refresh queued.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /content_ideas/{id}/dismiss:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Schedules & Plans]
      operationId: dismissContentIdea
      summary: Dismiss a content idea
      description: "Requires scope: `content_ideas:write`. Marks the idea dismissed. Returns 200 with an empty body."
      responses:
        "200":
          description: Dismissed.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /content_ideas/{id}/use:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Schedules & Plans]
      operationId: useContentIdea
      summary: Use a content idea
      description: "Requires scope: `content_ideas:write`. Marks the idea used and returns a seed prompt for generation."
      responses:
        "200":
          description: The seed prompt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  seed_prompt: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /posts/generate_from_idea:
    post:
      tags: [AI Generation]
      operationId: generatePostFromIdea
      summary: Generate post content from a content idea
      description: "Requires scope: `ai:generate` and a paid plan. Subject to your plan's daily generation quota. `idea` is typically the `seed_prompt` returned by `POST /content_ideas/{id}/use`, but any topic string is accepted."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                idea: { type: string, maxLength: 500 }
                platform: { $ref: "#/components/schemas/Platform" }
                variants:
                  type: integer
                  description: "Request several drafts to choose between, returned as `drafts` instead of `body`. Clamped to 2-3, then down to your remaining daily quota. On a premium model this is forced to 1 — each draft is a separately billed generation, so returning several would charge you for drafts you discard. A premium model that fell back to the default (empty balance, lapsed plan) is not billed and still returns the full set."
            example: { idea: "Why solo founders should ship in public", platform: "twitter" }
      responses:
        "200":
          description: "Generated content. Returns `body` for a single generation, or `drafts` when `variants` was requested."
          content:
            application/json:
              schema:
                type: object
                properties:
                  body: { type: string }
                  drafts:
                    type: array
                    description: "Present instead of `body` when `variants` was requested. Holds a single element on a premium model."
                    items:
                      type: object
                      properties:
                        body: { type: string }
                        model_degraded: { type: boolean }
                  model_degraded:
                    type: boolean
                    description: "True when this was generated with the default model instead of the premium model you selected, because your AI balance is empty. Generation is never blocked for an empty balance — it falls back so the work still completes."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /voice_profile:
    get:
      tags: [Voice]
      operationId: getVoiceProfile
      summary: Get the voice profile
      description: "Requires scope: `voice:read`. Returns the trained voice profile and per-platform summaries. Raw archive and embeddings are never exposed."
      responses:
        "200":
          description: The voice profile.
          content:
            application/json:
              schema:
                type: object
                properties:
                  voice_profile: { $ref: "#/components/schemas/VoiceProfile" }
                  platforms:
                    type: array
                    items: { $ref: "#/components/schemas/PlatformVoice" }
              example:
                voice_profile:
                  id: 88
                  platform_managed: true
                  voice_preset_id: 3
                  voice_preset_name: "Direct & punchy"
                  analyzed_at: "2026-05-10T00:00:00Z"
                  tweet_count: 1240
                  writing_style: { sentence_length: "short", formality: "casual" }
                  sample_posts: ["just shipped a new feature", "debugging is my cardio"]
                platforms:
                  - platform: "twitter"
                    post_count: 1240
                    analyzed_at: "2026-05-10T00:00:00Z"
                    sample_posts: ["just shipped a new feature", "debugging is my cardio"]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /billing/subscriptions/current:
    get:
      tags: [Billing]
      operationId: getCurrentSubscription
      summary: Get the current subscription and quota
      description: "Requires scope: `billing:read`. Read-only. Billing mutations (subscribe, cancel, change plan) are dashboard-only."
      responses:
        "200":
          description: The current subscription, tier, and quota.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription:
                    oneOf:
                      - $ref: "#/components/schemas/Subscription"
                      - type: "null"
                  tier: { type: string, enum: [free, starter, pro, team] }
                  quota: { $ref: "#/components/schemas/Quota" }
                  max_schedule_days: { type: integer }
              example:
                subscription:
                  id: 6001
                  status: "active"
                  current_period_end: "2026-08-01T00:00:00Z"
                  cancel_at_period_end: false
                  days_until_renewal: 19
                tier: "pro"
                quota:
                  tier: "pro"
                  daily_limit: 100
                  replies_used_today: 12
                  replies_remaining_today: 88
                  resets_at: "2026-07-13T23:59:59Z"
                max_schedule_days: 30
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /image_week_runs:
    get:
      tags: [Quote Week]
      operationId: listImageWeekRuns
      summary: List Quote Week runs
      description: "Requires scope: `image_week:read`. Returns the 20 most recent Quote Week runs for the authenticated user, ordered by `created_at` descending."
      responses:
        "200":
          description: A list of Quote Week runs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  image_week_runs:
                    type: array
                    items: { $ref: "#/components/schemas/ImageWeekRun" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Quote Week]
      operationId: createImageWeekRun
      summary: Start a Quote Week run
      description: |
        Requires scope: `image_week:write` and a paid plan. Creates an
        `ImageWeekRun` and enqueues `ImageWeekRunJob`, which plans a week of
        AI quote-card images (Instagram/Pinterest) and generates them with a
        vision-verify retry loop. Subject to the plan's monthly quota (4
        runs/month on Starter, 12 on Pro/Team) — `dry_run: true` requests are
        exempt.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImageWeekRunInput" }
            example:
              topic: "resilience"
              platform_config:
                instagram: { social_account_id: 12 }
                pinterest: { social_account_id: 14, board_id: "board_123", link: "https://example.com" }
              cutoff_days: 7
              schedule_mode: "draft"
              watermark_enabled: false
              dry_run: false
      responses:
        "201":
          description: The created run.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImageWeekRunEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Validation failed, or the monthly Quote Week quota is exhausted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                invalid_parameters:
                  value: { error: { message: "board_id is required for Pinterest", code: "INVALID_PARAMETERS" } }
                quota_exceeded:
                  value: { error: { message: "You have reached your monthly Quote Week quota. It resets at the start of next month.", code: "quota_exceeded" } }

  /image_week_runs/{id}:
    get:
      tags: [Quote Week]
      operationId: getImageWeekRun
      summary: Get a Quote Week run
      description: "Requires scope: `image_week:read`. Poll for run progress. Returns the run plus its full item list."
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: The run and its items.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImageWeekRunWithItemsEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /image_week_runs/{id}/cancel:
    post:
      tags: [Quote Week]
      operationId: cancelImageWeekRun
      summary: Cancel a Quote Week run
      description: "Requires scope: `image_week:write`. Cancels a run that hasn't reached a terminal status yet. No-op if the run is already `completed`, `failed`, or `canceled`."
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: The run, canceled (or unchanged if already terminal).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImageWeekRunEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /image_week_runs/{image_week_run_id}/items/{id}/regenerate:
    post:
      tags: [Quote Week]
      operationId: regenerateImageWeekItem
      summary: Regenerate a failed Quote Week item
      description: "Requires scope: `image_week:write`. Reprocesses a single `failed` item — resets its attempt count and re-enqueues generation → verify → post-build → slot-fill for that item alone. Only `failed` items can be regenerated (422 `INVALID_STATE` otherwise)."
      parameters:
        - name: image_week_run_id
          in: path
          required: true
          schema: { type: integer }
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: The parent run and its items (same shape as `GET /image_week_runs/{id}`), item now pending reprocessing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImageWeekRunWithItemsEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: The item is not in `failed` status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { message: "Only failed items can be regenerated", code: "INVALID_STATE" } }

  /site_pushes:
    post:
      tags: [Site Pushes]
      operationId: createSitePush
      summary: Push an article from a connected site
      security: [{ SiteConnectionToken: [] }]
      description: "Called by a CMS plugin (e.g. WordPress) when a post is published or updated. Stores the article for the connection's automation rules; enabled rules then create drafts or scheduled posts. Re-pushing the same URL updates the stored article instead of duplicating it. Rate limit: 30 requests per minute per token."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action, url, title]
              properties:
                action:
                  type: string
                  enum: [publish, update]
                url:
                  type: string
                  description: Canonical permalink of the article. Also the upsert key per connection.
                title: { type: string }
                body_html:
                  type: string
                  description: Rendered article HTML. Exactly one of `body_html` or `body_text` is required; HTML is converted to plain text server-side.
                body_text:
                  type: string
                  description: Plain-text article body, used as-is.
                excerpt: { type: string }
                post_type: { type: string }
                published_at: { type: string, format: date-time }
                author:
                  type: object
                  description: Author fields (e.g. `display_name`, `user_nicename`).
                  additionalProperties: true
                taxonomies:
                  type: object
                  description: Map of taxonomy slug to an array of term names.
                  additionalProperties:
                    type: array
                    items: { type: string }
                featured_image_url:
                  type: string
                  description: Stored for rule usage; never fetched by the server at push time.
                custom_fields:
                  type: object
                  additionalProperties: true
            example:
              action: publish
              url: "https://example.com/2026/07/hello-world/"
              title: "Hello World"
              body_html: "<p>Article body.</p>"
              excerpt: "Article excerpt."
              post_type: post
              published_at: "2026-07-28T14:45:00Z"
              author: { display_name: "John" }
              taxonomies: { category: ["Engineering"] }
      responses:
        "201":
          description: The article was stored and rule processing enqueued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  url: { type: string }
                  action: { type: string, enum: [publish, update] }
                  received_at: { type: string, format: date-time }
              example: { id: 42, url: "https://example.com/2026/07/hello-world/", action: publish, received_at: "2026-07-28T14:45:07Z" }
        "200":
          description: An already-stored article was updated by a re-push.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  url: { type: string }
                  action: { type: string, enum: [publish, update] }
                  received_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: A required field is missing or invalid; the message names the field.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { message: "url must be an http(s) URL", code: "INVALID_PARAMETERS" } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /site_pushes/status:
    get:
      tags: [Site Pushes]
      operationId: getSitePushStatus
      summary: Get the connection's status and recent pushes
      security: [{ SiteConnectionToken: [] }]
      description: "Read-only status for the authenticated Site Connection: connection details, its automation rules, and the 10 most recent pushed articles. Powers the plugin's Test Connection button and status displays."
      responses:
        "200":
          description: Connection status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  connection:
                    type: object
                    properties:
                      name: { type: string }
                      site_url: { type: string }
                      cms: { type: string }
                      last_push_at: { type: string, format: date-time, nullable: true }
                      unmatched_pushes:
                        type: integer
                        description: Count of pushes that arrived while no enabled rule matched.
                  rules:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        platform: { type: string }
                        trigger: { type: string, enum: [publish, update, both] }
                        schedule_mode: { type: string, enum: [draft, next_slot] }
                        enabled: { type: boolean }
                  recent_pushes:
                    type: array
                    items:
                      type: object
                      properties:
                        url: { type: string }
                        title: { type: string }
                        last_pushed_at: { type: string, format: date-time, nullable: true }
                        consumed_at: { type: string, format: date-time, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: A personal API key created in the dashboard (Starter, Pro, and Team plans).
    SiteConnectionToken:
      type: http
      scheme: bearer
      description: "A Site Connection token created in the dashboard (Settings > Sites). Scoped to one connected site: it can only push that site's articles and read that connection's status. Personal API keys and dashboard sessions cannot call Site Push endpoints, and this token cannot call any other endpoint."

  parameters:
    PostId:
      name: id
      in: path
      required: true
      schema: { type: integer }
    PublishPostId:
      name: post_id
      in: path
      required: true
      schema: { type: integer }
    RuleId:
      name: id
      in: path
      required: true
      schema: { type: integer }
    PresignContentType:
      name: content_type
      in: query
      required: true
      schema: { type: string }
    PresignFileSize:
      name: file_size
      in: query
      required: true
      description: File size in bytes.
      schema: { type: integer }
    StartDate:
      name: start_date
      in: query
      description: Inclusive start date (YYYY-MM-DD).
      schema: { type: string, format: date }
    EndDate:
      name: end_date
      in: query
      description: Inclusive end date (YYYY-MM-DD).
      schema: { type: string, format: date }

  responses:
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { message: "Unauthorized. Please provide a valid authentication token.", code: "UNAUTHORIZED" } }
    Forbidden:
      description: Authenticated but not permitted (insufficient scope, wrong tier, endpoint not available to API keys, or wrong workspace).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { message: "Insufficient scope. This action requires: posts:write.", code: "INSUFFICIENT_SCOPE" } }
    NotFound:
      description: The resource does not exist or is not accessible.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { message: "Post not found", code: "NOT_FOUND" } }
    UnprocessableEntity:
      description: Validation failed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { message: "Topic must be 280 characters or fewer", code: "INVALID_PARAMETERS" } }
    RateLimited:
      description: Rate limit or daily generation quota exceeded. A `Retry-After` header (seconds) is included.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { message: "Rate limit exceeded. Please try again later.", code: "RATE_LIMIT_EXCEEDED", retry_after: 60 } }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            message: { type: string }
            code: { type: string }
            details:
              description: Optional extra context — a string or an array of validation messages.
              type: [string, array, "null"]
              items: { type: string }
            retry_after: { type: integer, description: "Present on 429 responses." }
          required: [message]

    Platform:
      type: string
      enum: [twitter, linkedin, youtube, instagram, threads, pinterest, tiktok, bluesky, mastodon, facebook, google_business, discord, telegram, tumblr, slack]

    PageMeta:
      type: object
      properties:
        limit: { type: integer }
        offset: { type: integer }
        total_count: { type: integer }

    PostCounts:
      type: object
      properties:
        scheduled: { type: integer }
        draft: { type: integer }
        posted: { type: integer }
        failed: { type: integer, description: "Includes partial failures." }

    User:
      type: object
      properties:
        id: { type: integer }
        email: { type: string }
        tier: { type: string, enum: [free, starter, pro, team] }
        ai_credits_remaining:
          type: [integer, "null"]
          description: "Lifetime AI generations left on the free tier (starts at 10, consumed by POST /posts/generate_from_idea). Null on every paid tier, whose generation limits are the daily quota instead."
        subscription_status: { type: [string, "null"] }
        subscription_ends_at: { type: [string, "null"], format: date-time }
        cancel_at_period_end: { type: [boolean, "null"] }
        trial_used: { type: boolean }
        has_active_subscription: { type: boolean }
        created_at: { type: [string, "null"], format: date-time }
        team:
          oneOf:
            - type: object
              properties:
                id: { type: integer }
                name: { type: string }
                role: { type: string, enum: [admin, member, viewer] }
            - type: "null"
        workspaces:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              role: { type: string }

    PostContent:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        platform: { $ref: "#/components/schemas/Platform" }
        body: { type: string }
        content_type:
          type: string
          enum: [text, single_image, multi_image, video, document, poll]
        position:
          type: integer
          description: "0-based position within a multi-post thread chain (X/Bluesky/Threads); omit for regular contents. On update, positioned rows upsert on (platform, position), and for any platform submitted with positions, existing rows at positions outside the submitted set are deleted — shrinking a thread removes its trailing parts."
        first_comment: { type: [string, "null"] }
        metadata:
          type: object
          description: "Platform-specific attachment references and options (e.g. media_id, asset_urn, document_urn, r2_key, board_id, community_id, quote_post_id — Threads only, ID of an existing Threads post to quote). X also accepts reply_settings (one of following, mentionedUsers, subscribers, verified — omit the field for everyone, which is the default and is not a valid create value), the boolean disclosure labels made_with_ai and paid_partnership (sent only when true), and quote_tweet_id (a numeric tweet ID string to quote-tweet — X-only, unrelated to the Threads quote_post_id key; a malformed value is silently omitted). For content_type poll on X, metadata accepts options (array of 2-4 strings, each 1-25 chars) and duration_minutes (integer, 5-10080); X has no separate poll question field, the tweet body is the question. LinkedIn polls instead use question (max 140 chars), options (2-4 strings, each max 30 chars), and duration (one of ONE_DAY, THREE_DAYS, SEVEN_DAYS, FOURTEEN_DAYS)."
          additionalProperties: true
      required: [platform, body]

    PostInput:
      type: object
      properties:
        post_contents:
          type: array
          items: { $ref: "#/components/schemas/PostContent" }
        social_account_ids:
          type: array
          items: { type: integer }
        scheduled_at:
          type: string
          format: date-time
          description: ISO 8601 UTC. Omit for a draft. Ignored when `use_next_slot` is true.
        use_next_slot:
          type: boolean
          description: Queue the post into the next open posting-schedule slot instead of using `scheduled_at`. Returns `422 NO_SLOT_AVAILABLE` if no slot is open.
        dry_run:
          type: boolean
          description: "Run all validations (accounts, content, char limits, scheduled_at, tier gates) and return the would-be post without persisting anything. The response carries `dry_run: true`."
        flexible:
          type: boolean
          description: Allow the scheduler to shift this post to the nearest open slot.
        recurrence_frequency:
          type: [string, "null"]
          enum: [daily, weekly, monthly, null]
          description: Recurrence requires a paid plan.
        recurrence_interval: { type: [integer, "null"] }
        recurrence_weekdays:
          type: array
          items: { type: integer }
        recurrence_monthday: { type: [integer, "null"] }
        repeat_until: { type: [string, "null"], format: date-time }
        recurrence_jitter_enabled:
          type: boolean
          description: When true, each materialized occurrence is shifted by up to ±15 minutes instead of posting at the exact anchor time.
        evergreen:
          type: boolean
          description: Mark this post for evergreen recycling (re-queued into open slots after a fixed per-platform cooldown once it performs). Requires a paid plan — returns 403 `TIER_UPGRADE_REQUIRED` otherwise.
        evergreen_variations:
          type: array
          maxItems: 5
          items: { type: string, maxLength: 2000 }
          description: User-approved rewrite variants. When present, recycles cycle through these instead of AI-rewriting at publish time.
      required: [post_contents]

    Post:
      type: object
      properties:
        id: { type: integer }
        status:
          type: string
          enum: [draft, needs_review, approved, scheduled, processing, posted, failed, partial_failure, upload_pending]
        flexible: { type: boolean }
        scheduled_at: { type: [string, "null"], format: date-time }
        posted_at: { type: [string, "null"], format: date-time }
        failed_at: { type: [string, "null"], format: date-time }
        post_error: { type: [string, "null"] }
        disconnected: { type: boolean }
        source: { type: [string, "null"] }
        recurrence_jitter_enabled: { type: boolean }
        evergreen: { type: boolean }
        evergreen_variations:
          type: array
          items: { type: string }
        recycled_from_id:
          type: [integer, "null"]
          description: ID of the evergreen post this one was recycled from; null unless the post was created by evergreen recycling.
        reviewed_at: { type: [string, "null"], format: date-time }
        review_action: { type: [string, "null"] }
        rejection_reason: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        body:
          type: [string, "null"]
          description: "Backward-compatible alias: the X (Twitter) content body, if present."
        post_contents:
          type: array
          items: { $ref: "#/components/schemas/PostContent" }
        post_accounts:
          type: array
          items: { $ref: "#/components/schemas/PostAccount" }
        dry_run:
          type: boolean
          description: Present and true only on responses to a `dry_run` request; the post was not persisted.

    PostAccount:
      type: object
      properties:
        id: { type: integer }
        social_account_id: { type: integer }
        platform: { type: [string, "null"] }
        username: { type: [string, "null"] }
        platform_status: { type: [string, "null"] }
        platform_post_id: { type: [string, "null"] }
        permalink:
          type: [string, "null"]
          description: The live post URL, constructed server-side once published. Null before publishing or on platforms without a reliably constructible public URL.
        platform_error: { type: [string, "null"] }
        dub_short_url:
          type: [string, "null"]
          description: "Branded short link substituted into the body at publish time. Null when short links are off or nothing was minted."
        dub_original_url:
          type: [string, "null"]
          description: "The URL the short link replaced. Kept because the published body no longer contains it."
        post_metric:
          oneOf:
            - $ref: "#/components/schemas/PostMetric"
            - type: "null"

    PostMetric:
      type: object
      properties:
        impressions: { type: integer }
        likes: { type: integer }
        replies: { type: integer }
        reposts: { type: integer }
        clicks: { type: integer }
        engagement_rate: { type: number }
        fetched_at: { type: [string, "null"], format: date-time }

    PostEnvelope:
      type: object
      properties:
        post: { $ref: "#/components/schemas/Post" }

    BulkResult:
      type: object
      properties:
        created:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              status: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              message: { type: string }

    SocialAccount:
      type: object
      properties:
        id: { type: integer }
        platform: { $ref: "#/components/schemas/Platform" }
        uid: { type: [string, "null"] }
        username: { type: [string, "null"] }
        email: { type: [string, "null"] }
        connected_at: { type: [string, "null"], format: date-time }
        avatar_url: { type: [string, "null"] }
        needs_reconnect: { type: boolean }

    PlatformCapability:
      type: object
      properties:
        platform: { type: string, example: "twitter" }
        char_limit: { type: [integer, "null"], description: "Maximum body length for the platform." }
        content_types:
          type: array
          items: { type: string }
          description: "Content types this platform can publish. Per-platform truth — not every platform supports every type."
        max_images:
          type: integer
          description: "Maximum images in a multi-image post. Omitted for platforms with no image-count limit."
        media:
          type: object
          description: "Media upload limits. Omitted for platforms without an upload config."
          properties:
            allowed_content_types:
              type: array
              items: { type: string }
            size_limits:
              type: object
              additionalProperties:
                type: object
                properties:
                  bytes: { type: integer }
                  label: { type: string }
      required: [platform, char_limit, content_types]

    Base64ImageUpload:
      type: object
      description: "`media` is accepted as an alias for `image`; when both are present, `media` wins."
      properties:
        content_type: { type: string, example: "image/jpeg" }
        image: { type: string, description: "Base64-encoded image bytes. Alias: `media`." }
        media: { type: string, description: "Alias for `image`. Takes precedence when both are supplied." }
      required: [content_type]

    UploadResult:
      type: object
      properties:
        url: { type: string }
        r2_key: { type: string }

    PlatformMediaUploadInput:
      type: object
      description: |
        Base64 media upload for a platform-scoped endpoint. Allowed content
        types and size limits vary by platform (e.g. images only for Facebook,
        Pinterest, Threads; images and video for Instagram).
      properties:
        content_type: { type: string, example: "image/jpeg" }
        media: { type: string, description: "Base64-encoded file bytes." }
      required: [content_type, media]

    MediaDeleteInput:
      type: object
      properties:
        r2_key: { type: string, description: "The r2_key returned when the media was uploaded." }
      required: [r2_key]

    PresignResult:
      type: object
      properties:
        upload_url: { type: string, description: "Presigned URL to PUT the file to." }
        r2_key: { type: string, description: "Reference to use in a post's metadata afterward." }

    AnalyticsRollup:
      type: object
      properties:
        social_account_id: { type: integer }
        platform: { type: string }
        total_posts_with_metrics: { type: integer }
        total_impressions: { type: integer }
        total_likes: { type: integer }
        total_replies: { type: integer }
        total_reposts: { type: integer }
        total_clicks: { type: integer }
        avg_impressions: { type: number }
        avg_engagement_rate: { type: number }
        prior_total_impressions:
          type: [integer, "null"]
          description: "Raw impressions total for the same account over the prior window. Null means the account had no data in the prior window; 0 means it had data that summed to zero. Provided so a client can aggregate deltas across several accounts, which percentages cannot do."
        prior_total_likes: { type: [integer, "null"] }
        prior_total_replies: { type: [integer, "null"] }
        prior_total_reposts: { type: [integer, "null"] }
        prior_total_posts_with_metrics: { type: [integer, "null"] }
        pct_change_impressions: { type: [number, "null"] }
        pct_change_engagement_rate: { type: [number, "null"] }

    TimeseriesPoint:
      type: object
      properties:
        date: { type: string, format: date }
        impressions: { type: integer }
        engagement_rate: { type: number }
      additionalProperties: true

    BestTimeCell:
      type: object
      properties:
        day_of_week: { type: integer, description: "0 = Sunday .. 6 = Saturday." }
        hour: { type: integer, minimum: 0, maximum: 23 }
        avg_engagement_rate: { type: number }
        post_count: { type: integer }

    Preferences:
      type: object
      properties:
        tone: { type: [string, "null"] }
        include_emoji: { type: [boolean, "null"] }
        structure: { type: [string, "null"] }
        custom_rules: { type: [string, "null"] }
        timezone: { type: [string, "null"] }
        use_post_metrics_for_ai: { type: boolean }
        theme:
          type: string
          enum: [light, dark, system]
          description: "Dashboard appearance. system follows the device setting."
        ai_model:
          type: string
          nullable: true
          description: "Registry key of the chosen AI model. Null means the surface default is used."
        available_ai_models:
          type: array
          description: "Models selectable for reply generation. Excludes any whose provider route has no configured credential, so every entry is runnable."
          items:
            type: object
            properties:
              id: { type: string }
              label: { type: string }
              premium:
                type: boolean
                description: "True for models that cost materially more per token. Never a default."
              typical_cost_micros:
                type: integer
                description: "Indicative spend for one generation, in USD micros (10000 = 1 cent). A shopping figure for comparing models, never a quote — the ledger debits the measured cost of the actual call. Zero for included models."
        email_notifications:
          type: object
          properties:
            post_failure: { type: boolean }
            weekly_briefing: { type: boolean }
            product_tips: { type: boolean }
            re_engagement: { type: boolean }

    PreferencesInput:
      type: object
      properties:
        tone: { type: string }
        include_emoji: { type: boolean }
        structure: { type: string }
        custom_rules: { type: string }
        timezone: { type: string }
        use_post_metrics_for_ai: { type: boolean }
        theme:
          type: string
          enum: [light, dark, system]
          description: "Dashboard appearance. system follows the device setting."
        ai_model:
          type: string
          nullable: true
          description: "Registry key of an AI model offered in available_ai_models. Send null or an empty string to clear the choice and fall back to the surface default. An unrecognised key is rejected with 422 INVALID_AI_MODEL."
        email_notifications:
          type: object
          properties:
            post_failure: { type: boolean }
            weekly_briefing: { type: boolean }
            product_tips: { type: boolean }
            re_engagement: { type: boolean }

    PlatformStyle:
      type: object
      properties:
        tone: { type: [string, "null"] }
        structure: { type: [string, "null"] }
        target_length: { type: [integer, "null"] }
        max_length: { type: [integer, "null"] }
        hashtag_rules: { type: [string, "null"] }
        cta_rules: { type: [string, "null"] }
        banned_phrases:
          type: array
          items: { type: string }
        custom_instructions: { type: [string, "null"] }
        is_default: { type: boolean }

    PlatformStyleInput:
      type: object
      properties:
        tone: { type: string }
        target_length: { type: integer }
        hashtag_rules: { type: string }
        cta_rules: { type: string }
        custom_instructions: { type: string }
        banned_phrases:
          type: array
          items: { type: string }

    CustomRule:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        content: { type: string }
        active: { type: boolean }
        scope:
          type: string
          enum: [reply, post]
          description: "Which generation surface the rule applies to. One rule may be active per scope; activating a rule deactivates only other rules with the same scope."
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    CustomRuleInput:
      type: object
      properties:
        title: { type: string }
        content: { type: string }
        active: { type: boolean }
        scope:
          type: string
          enum: [reply, post]
          default: reply
          description: "Defaults to reply, matching the behavior of rules created before scoping existed."
      required: [title, content]

    PostingSchedule:
      type: object
      properties:
        id: { type: integer }
        social_account_id: { type: integer }
        timezone: { type: [string, "null"] }
        min_gap_minutes: { type: [integer, "null"] }
        max_posts_per_day: { type: [integer, "null"] }
        paused: { type: boolean }
        jitter_enabled: { type: boolean }
        anti_duplicate_enabled: { type: boolean }
        min_gap_enabled: { type: boolean }
        max_per_day_enabled: { type: boolean }
        evergreen_fill_enabled: { type: boolean }
        evergreen_ai_refresh_enabled: { type: boolean }
        posting_slots:
          type: array
          items: { $ref: "#/components/schemas/PostingSlot" }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    PostingSlot:
      type: object
      properties:
        id: { type: integer }
        day_of_week: { type: integer }
        time_of_day: { type: string, example: "09:00" }
        enabled: { type: boolean }
        source: { type: string, enum: [manual, auto] }

    PostingScheduleInput:
      type: object
      properties:
        social_account_id: { type: integer }
        timezone: { type: string }
        min_gap_minutes: { type: integer }
        max_posts_per_day: { type: integer }
        paused: { type: boolean }
        jitter_enabled: { type: boolean }
        anti_duplicate_enabled: { type: boolean }
        min_gap_enabled: { type: boolean }
        max_per_day_enabled: { type: boolean }
        evergreen_fill_enabled:
          type: boolean
          description: Turn on evergreen recycling for this schedule's account (paid feature; the fill job skips non-paid users). Recycle cooldowns are fixed per platform and not configurable. YouTube and TikTok schedules never recycle regardless of this setting.
        evergreen_ai_refresh_enabled:
          type: boolean
          description: Rewrite each recycled post's wording with AI before scheduling; falls back to the original body on failure. Always applied on X, Threads, Bluesky, and Mastodon regardless of this setting — there a failed rewrite skips that recycle instead of posting verbatim.
        posting_slots_attributes:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              day_of_week: { type: integer }
              time_of_day: { type: string }
              enabled: { type: boolean }
              _destroy: { type: boolean }

    ContentPlan:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        active: { type: boolean }
        use_sources: { type: boolean }
        platform_configs:
          type: object
          additionalProperties: true
          description: "Legacy per-platform config keyed by platform name, with exactly one entry per platform. Instagram/Pinterest entries may set format: quote_cards (plus watermark_enabled, watermark_text, optional watermark_every_n, optional carousel_every_n, and quote_topic; Pinterest requires board_id) to have the weekly run generate Quote Week image posts for that platform. carousel_every_n (integer 1-50, default null) makes every Nth quote post on that platform a carousel; null means never generate a carousel. Pinterest never produces carousels regardless of this setting. Platforms that require media on every post (instagram, pinterest, tiktok, youtube) are rejected with 422 unless the entry sets format: quote_cards, because they cannot publish the plain-text posts the plan's text lane generates; tiktok and youtube have no quote-card renderer and therefore no valid configuration. Because this shape is keyed by platform it can hold only ONE account per platform - when a plan has two accounts on the same platform only the first appears here. Read channels for the complete account-grained set."
        channels:
          type: array
          description: "Account-grained plan configuration - one entry per connected account, and the only shape that can represent two accounts on the same platform. Prefer this over platform_configs. Writes accept either shape; when both are sent, channels wins."
          items:
            type: object
            properties:
              social_account_id: { type: integer }
              platform: { type: string }
              format: { type: [string, "null"] }
              board_id: { type: [string, "null"] }
              angles: { type: array, items: { type: string } }
              posts_per_day: { type: [integer, "null"] }
              quote_topic: { type: [string, "null"] }
              watermark_enabled: { type: boolean }
              watermark_text: { type: [string, "null"] }
              watermark_every_n: { type: [integer, "null"] }
              carousel_every_n: { type: [integer, "null"] }
              active: { type: boolean }
        content_strategy: { type: [string, "null"] }
        schedule_day: { type: [string, "null"] }
        schedule_time: { type: [string, "null"] }
        last_run_at: { type: [string, "null"], format: date-time }
        next_run_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    VoiceProfile:
      type: object
      properties:
        id: { type: [integer, "null"] }
        platform_managed: { type: boolean }
        voice_preset_id: { type: [integer, "null"] }
        voice_preset_name: { type: [string, "null"] }
        analyzed_at: { type: [string, "null"], format: date-time }
        tweet_count: { type: integer }
        writing_style: { type: object, additionalProperties: true }
        sample_posts:
          type: array
          items: { type: string }
          description: "The text of the posts this profile's writing_style was trained on, most recent last."
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    PlatformVoice:
      type: object
      properties:
        platform: { type: string }
        post_count: { type: integer }
        analyzed_at: { type: [string, "null"], format: date-time }
        writing_style: { type: object, additionalProperties: true }
        sample_posts:
          type: array
          items: { type: string }
          description: "The text of the posts this platform's writing_style was trained on, most recent last."

    Subscription:
      type: object
      properties:
        id: { type: integer }
        status: { type: string }
        current_period_start: { type: [string, "null"], format: date-time }
        current_period_end: { type: [string, "null"], format: date-time }
        cancel_at_period_end: { type: boolean }
        canceled_at: { type: [string, "null"], format: date-time }
        days_until_renewal: { type: [integer, "null"] }

    Quota:
      type: object
      properties:
        tier: { type: [string, "null"], description: "\"free\" for free-tier users; null when the subscription has lapsed." }
        daily_limit: { type: [integer, "null"], description: "Null means unlimited." }
        replies_used_today: { type: integer }
        replies_remaining_today: { type: integer }
        resets_at: { type: [string, "null"], format: date-time }
        requires_subscription: { type: boolean, description: "True when AI reply generation needs a paid plan or trial. Clients gate generation on this field. Absent on paid tiers." }
        message: { type: string, description: "Human-readable quota status for display. Absent when a daily quota is being reported." }

    ImageWeekRunInput:
      type: object
      required: [topic, platform_config]
      properties:
        topic:
          type: string
          description: "Drives the quote-generation prompt and the weekly Instagram carousel's cover slide."
        platform_config:
          type: object
          description: "Map of platform name (`instagram` or `pinterest` only) to `{ social_account_id, board_id?, link? }`. `social_account_id` must belong to the authenticated user. `board_id` is required for Pinterest."
          additionalProperties:
            type: object
            properties:
              social_account_id: { type: integer }
              board_id: { type: string, description: "Required for Pinterest." }
              link: { type: string, description: "Optional Pinterest pin destination; defaults to the user's website field." }
            required: [social_account_id]
        cutoff_days:
          type: integer
          minimum: 7
          maximum: 13
          default: 7
          description: How many days ahead to plan posting slots.
        schedule_mode:
          type: string
          enum: [draft, auto]
          default: draft
        watermark_enabled: { type: boolean, default: false }
        watermark_text: { type: [string, "null"] }
        watermark_every_n:
          type: [integer, "null"]
          minimum: 1
          maximum: 50
          description: "Brand only every Nth generated image, counted per platform. For runs created by a content plan the count is persisted on the plan and carried across runs (it does not reset week to week); for ad-hoc runs it is counted within the run. Omit to watermark every image when watermark_enabled is true."
        dry_run:
          type: boolean
          default: false
          description: "Runs the full pipeline but creates no ScheduledPost rows and does not count against the monthly quota."

    ImageWeekRun:
      type: object
      properties:
        id: { type: integer }
        status:
          type: string
          enum: [pending, planning, generating, completed, failed, canceled]
        content_plan_id:
          type: [integer, "null"]
          description: "Non-null when the run was created automatically by a content plan's quote-cards lane; such runs consume the plan's projected assignments and use the planner's needs_review + auto_approve flow"
        schedule_mode: { type: string, enum: [draft, auto] }
        topic: { type: string }
        pack: { type: [string, "null"] }
        platform_config: { type: object, additionalProperties: true }
        cutoff_days: { type: integer }
        watermark_enabled: { type: boolean }
        watermark_text: { type: [string, "null"] }
        watermark_every_n: { type: [integer, "null"] }
        dry_run: { type: boolean }
        template_version: { type: [string, "null"] }
        total_items: { type: integer }
        completed_items: { type: integer }
        failed_items: { type: integer }
        shortfall_reason:
          type: [string, "null"]
          enum: [quota, no_slots, generation_failures, null]
          description: "`quota` — plan was clamped against the remaining monthly quota. `no_slots` — no posting slots were available. `generation_failures` — one or more items failed after exhausting retries."
        error_message: { type: [string, "null"] }
        images_generated: { type: integer }
        retry_count: { type: integer }
        garble_count: { type: integer }
        est_cost_cents: { type: integer }
        duration_ms: { type: [integer, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    ImageWeekItem:
      type: object
      properties:
        id: { type: integer }
        scheduled_post_id: { type: [integer, "null"] }
        platform: { type: string }
        slot_at: { type: [string, "null"], format: date-time }
        item_type: { type: string, enum: [single_image, carousel] }
        status:
          type: string
          enum: [pending, generating, verifying, writing, queued, failed, skipped]
        quote_text: { type: [string, "null"] }
        rendered_text: { type: [string, "null"] }
        style_key: { type: [string, "null"] }
        watermarked: { type: boolean }
        attempts: { type: integer }
        fail_reason:
          type: [string, "null"]
          enum: [garbled, api_error, slot_gone, verify_error, null]
        image_r2_keys:
          type: array
          items: { type: string }

    ImageWeekRunEnvelope:
      type: object
      properties:
        image_week_run: { $ref: "#/components/schemas/ImageWeekRun" }

    ImageWeekRunWithItemsEnvelope:
      type: object
      properties:
        image_week_run: { $ref: "#/components/schemas/ImageWeekRun" }
        items:
          type: array
          items: { $ref: "#/components/schemas/ImageWeekItem" }
