openapi: 3.1.0
info:
  title: Juicer API v1
  version: 1.0.0
  description: |
    The Juicer API provides two products under one API key:

    1. **Integration API** — Programmatically manage feeds, sources, posts, and moderation
    2. **Data API** — Query social posts for a handle or hashtag without creating a feed

    ## Authentication
    All requests require a Bearer token in the Authorization header:
    ```
    Authorization: Bearer jcr_your_api_key_here
    ```
    API keys can be created from the Developer page in the Juicer dashboard,
    **or** programmatically via `POST /v1/authorize` — see step 0 of the
    Quick Start below. No prior signup or dashboard access is required.

    ## Quick Start

    Create a feed, add a source, and get the embed code:

    ```bash
    # 0. Get an API key (no dashboard needed)
    curl -X POST https://api.juicer.io/v1/authorize \
      -H "Content-Type: application/json" \
      -d '{"email": "you@example.com", "client_name": "My Tool"}'
    # New email → returns `api_key` immediately (temporary until email confirmed).
    # Existing user → returns an `authorization_url` (user approves) and a
    # `poll_url`; GET the poll_url until it returns the `api_key`.

    # 1. Check your account
    curl https://api.juicer.io/v1/account \
      -H "Authorization: Bearer jcr_your_key"

    # 2. See available platforms
    curl https://api.juicer.io/v1/platforms \
      -H "Authorization: Bearer jcr_your_key"

    # 3. Create a feed
    curl -X POST https://api.juicer.io/v1/feeds \
      -H "Authorization: Bearer jcr_your_key" \
      -H "Content-Type: application/json" \
      -d '{"name": "My Brand Feed"}'

    # 4. Add a YouTube source
    curl -X POST https://api.juicer.io/v1/feeds/{feed_id}/sources \
      -H "Authorization: Bearer jcr_your_key" \
      -H "Content-Type: application/json" \
      -d '{"platform": "YouTube", "term": "NASA", "term_type": "username"}'

    # 5. Get embed code for your website
    curl https://api.juicer.io/v1/feeds/{feed_id}/embed \
      -H "Authorization: Bearer jcr_your_key"
    ```

    The embed endpoint returns ready-to-use snippets:

    - **JavaScript** — `<script>` tag for any website
    - **iframe** — For platforms that don't allow JavaScript
    - **WordPress shortcode** — `[juicer name='your-feed']`
    - **WordPress PHP** — `<?php juicer_feed('name=your-feed'); ?>`

    ## Discovering Platforms

    Use `GET /platforms` to see all available social media platforms and their
    supported term types (username, hashtag, mentions, etc.) before creating sources.
    Each term type includes its OAuth connection requirements, so you know upfront
    whether the user needs to connect an account first.

    ## Connecting Social Accounts

    Several platforms require a connected social account before you can create
    sources. The exact list and the per-term-type requirement depend on the
    caller's account configuration — always discover requirements at runtime
    from `GET /platforms` (the `requires_connection` field on each term type)
    or `GET /social_accounts/status`. The flow when a connection is needed:

    1. `GET /social_accounts/status` — check which platforms need connecting
    2. `POST /social_accounts/connect_url` with `{"provider": "facebook"}` — generates a magic link
    3. User clicks the link — it signs them in and starts the authorization flow (no Juicer login needed)
    4. `GET /social_accounts` — verify the account is now connected
    5. Create sources for that platform as normal

    The magic link expires in 30 minutes and can only be used once. This makes
    it suitable for AI agents and CLI tools that need to walk a user through
    social account setup.

    **Note:** If you try to create a source that requires a connected account
    and none is available, the API returns a `422` error with
    `error.code = "social_account_required"` and an `error.action` containing
    a magic link the user can click to complete the connection.

    ## Plan Limits

    Your plan determines how many feeds and sources you can create. If you hit
    a limit, the API returns a `422` error with `error.code = "feed_limit_reached"`
    or `"source_limit_reached"`, along with an `error.action` of type `"upgrade_plan"`
    that includes a magic link to the upgrade page with the correct plan pre-selected.

servers:
  - url: https://api.juicer.io/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Quickstart
    description: Get an API key with just an email, no dashboard required
  - name: Account
    description: Account info, usage, and limits
  - name: Platforms
    description: Discover available platforms and term types
  - name: Social Accounts
    description: Manage OAuth social account connections needed for certain platforms
  - name: Feeds
    description: Manage feeds (create, update, delete, list)
  - name: Sources
    description: Manage sources within feeds
  - name: Posts
    description: Read, search, and moderate posts
  - name: Embed
    description: Get embed code snippets for your website
  - name: Search
    description: Search posts across all feeds
  - name: Analytics
    description: Feed engagement analytics and performance
  - name: Webhooks
    description: Webhook subscriptions and event delivery
  - name: Data API
    description: One-off post lookups for a handle or hashtag without creating a feed

paths:
  # ── Quickstart: authorize a caller without a pre-existing API key ───
  /authorize:
    post:
      operationId: authorize
      tags: [Quickstart]
      summary: Get an API key with an email (no dashboard, no prior signup)
      security: []
      description: |
        Provision an API key using just an email address. The behavior depends
        on whether the email already belongs to a confirmed Juicer account:

        - **New or unconfirmed email** — returns a short-lived (2 hour)
          temporary API key immediately (Mode A). Subject to a strict per-key
          rate limit until the user confirms the email we send. Once
          confirmed the same key is extended to the standard 12-hour
          session TTL — it is not promoted to a permanent key.
        - **Existing confirmed user** — returns a **device authorization**
          request (Mode B) with an `authorization_url` the user must visit to
          approve access. Poll `poll_url` until the key is issued. The
          resulting key is also a 12-hour temporary key.

        **Every key issued by this endpoint is time-bounded.** For a long-lived
        key, users should generate one from the Developer page in the Juicer
        dashboard; that is the only path to a permanent key. The dashboard key
        and any number of `/authorize`-issued keys coexist — issuing a new
        session key never revokes an existing permanent or session key.

      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  example: ai-dev@example.com
                client_name:
                  type: string
                  description: |
                    Optional free-text name of the calling application. Shown
                    verbatim on the authorization page for Mode B (for example
                    "Acme CLI" or "My Feed Tool"). If omitted, the page uses a
                    generic fallback.
                  example: Acme CLI
      responses:
        "201":
          description: |
            Mode A — temporary API key issued immediately. Valid for 2 hours
            pre-confirmation; extended to 12 hours once the user confirms
            their email. `expires_at` is always present.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: issued }
                  api_key: { type: string, example: jcr_9f...ab }
                  expires_at: { type: string, format: date-time }
                  email_confirmation_required: { type: boolean, example: true }
                  rate_limit:
                    type: object
                    description: The rate limit tier applied to this pre-confirmation key
                    properties:
                      requests_per_hour: { type: integer, example: 60 }
                      burst_per_minute: { type: integer, nullable: true, example: null }
                  upgrade_hint: { type: string, example: "Confirm your email to raise your rate limit to 300 requests/hour and extend this key to 12 hours." }
                  upgrade_url: { type: string, format: uri, description: Link to the plans page where the user can upgrade }
        "202":
          description: Mode B — device authorization required
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: pending_authorization }
                  request_id: { type: string }
                  authorization_url: { type: string, format: uri, description: URL the user opens in a browser to approve the request }
                  poll_url: { type: string, format: uri, description: URL the client polls for status }
                  expires_at: { type: string, format: date-time }
                  poll_interval: { type: integer, example: 2, description: Recommended poll interval in seconds }
                  rate_limit:
                    type: object
                    description: The rate limit tier applied to the key once approved
                    properties:
                      requests_per_hour: { type: integer, example: 300 }
                      burst_per_minute: { type: integer, example: 60 }
                  upgrade_hint: { type: string, example: "Visit the Juicer dashboard to upgrade your plan for higher limits." }
                  upgrade_url: { type: string, format: uri }
        "422":
          description: Invalid email
        "429":
          description: Too many requests from this IP or for this email

  /authorize/{request_id}:
    get:
      operationId: pollAuthorization
      tags: [Quickstart]
      summary: Poll the status of a device authorization request
      security: []
      description: |
        Poll for the outcome of a Mode B authorization request. Returns:
        - **202** — still pending (keep polling).
        - **200** — approved; the body contains a 12-hour temporary `api_key`.
          The key is returned **once**; subsequent polls return 410.
        - **410** — denied, expired, or already delivered.
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Approved; 12-hour temporary API key delivered
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: issued }
                  api_key: { type: string }
                  expires_at: { type: string, format: date-time, description: 12 hours from issuance }
                  key_preview: { type: string }
                  rate_limit:
                    type: object
                    description: The rate limit tier applied to this confirmed-session key
                    properties:
                      requests_per_hour: { type: integer, example: 300 }
                      burst_per_minute: { type: integer, example: 60 }
                  upgrade_hint: { type: string, example: "Visit the Juicer dashboard to upgrade your plan for higher limits." }
                  upgrade_url: { type: string, format: uri }
        "202":
          description: Still pending
        "410":
          description: Denied, expired, or already delivered
        "404":
          description: Unknown request_id

  /:
    get:
      operationId: apiIndex
      tags: [Quickstart]
      summary: API v1 endpoint index
      security: []
      description: Lists all v1 endpoints with a quickstart hint pointing at /v1/authorize. Intended for AI/CLI callers that haven't read this spec.
      responses:
        "200":
          description: Index
          content:
            application/json:
              schema:
                type: object

  # ── Account ─────────────────────────────────────────────────────
  /account:
    get:
      operationId: getAccount
      tags: [Account]
      summary: Get account info, usage, and limits
      responses:
        "200":
          description: Account info
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Account"

  # ── Platforms ───────────────────────────────────────────────────
  /platforms:
    get:
      operationId: listPlatforms
      tags: [Platforms]
      summary: List available platforms and term types
      description: |
        Returns all social media platforms that Juicer supports, along with
        the term types each platform accepts. Each term type includes its
        OAuth connection requirements, so you know upfront whether the user
        needs to connect an account before creating a source.

        The Data API (`GET /data/posts`) supports a narrower, connection-free
        subset: term types flagged `data_api: true`. `data_api_platforms` lists
        the platforms with at least one such term type. Pass `Twitter` for X on
        both APIs (the Data API also accepts `X`).
      responses:
        "200":
          description: Platforms listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Platform"
                  data_api_platforms:
                    type: array
                    description: Platforms with at least one term type usable with `GET /data/posts`
                    items:
                      type: string
                    example: ["Bluesky", "Facebook", "Flickr", "Giphy", "Google", "Instagram", "LinkedIn", "Pinterest", "Reddit", "TikTok", "Tumblr", "Twitter", "Vimeo", "YouTube"]

  # ── Social Accounts ─────────────────────────────────────────────
  /social_accounts:
    get:
      operationId: listSocialAccounts
      tags: [Social Accounts]
      summary: List connected social accounts
      description: |
        Returns all social accounts connected across the account
        (including accounts connected by other team members).
        Several platforms require a connected social account before you can
        create sources for them; use `GET /platforms` or
        `GET /social_accounts/status` to discover which apply to the caller.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: Social accounts listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/SocialAccount"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"

  /social_accounts/status:
    get:
      operationId: getSocialAccountStatus
      tags: [Social Accounts]
      summary: Check connection status per platform
      description: |
        Returns which platforms that require OAuth are connected and which need
        connecting. Use this before creating sources to know if the user needs
        to authorize first.
      responses:
        "200":
          description: Platform connection statuses
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/PlatformConnectionStatus"

  /social_accounts/connect_url:
    post:
      operationId: createSocialAccountConnectUrl
      tags: [Social Accounts]
      summary: Generate OAuth connection link
      description: |
        Generates a single-use magic link that signs the user in and redirects
        straight to the OAuth authorization page. The user does **not** need to
        be logged into Juicer — just clicking the link is enough.

        The link expires in 30 minutes and can only be used once.

        **Agent workflow:**
        1. Call this endpoint with the provider
        2. Present the `connection_url` to the user (e.g., "Click here to connect Instagram")
        3. User clicks, authorizes, and is redirected back to Juicer
        4. Poll `GET /social_accounts` or `GET /social_accounts/status` to confirm the connection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider:
                  type: string
                  description: |
                    OAuth provider name. Use the `provider` field from `GET /social_accounts/status`
                    or the `requires_connection.provider` field from `GET /platforms`.
                    Common values: `facebook`, `instagram_login`, `tiktok`, `slack`, `tumblr`.
                  example: "facebook"
      responses:
        "201":
          description: Magic link created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      provider:
                        type: string
                      connection_url:
                        type: string
                        description: Single-use magic link — signs user in and starts OAuth flow
                      expires_at:
                        type: string
                        format: date-time
                        description: Link expires after 30 minutes
                      allows_multiple:
                        type: boolean
                        description: Whether multiple accounts can be connected for this provider
                      note:
                        type: string
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /social_accounts/{id}:
    delete:
      operationId: deleteSocialAccount
      tags: [Social Accounts]
      summary: Disconnect a social account
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Social account disconnected
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Feeds ───────────────────────────────────────────────────────
  /feeds:
    get:
      operationId: listFeeds
      tags: [Feeds]
      summary: List all feeds
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: Feeds listed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Feed"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"

    post:
      operationId: createFeed
      tags: [Feeds]
      summary: Create a new feed
      description: |
        Creates a new feed. Returns `422` with `error.code = "feed_limit_reached"`
        and an upgrade action if your plan's feed limit is exceeded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Feed name
                  example: "My Brand Feed"
                sync_interval:
                  type: integer
                  nullable: true
                  description: >
                    How often the feed's sources are synced, in minutes. Allowed values are the
                    standard sync frequencies `1`, `5`, `10`, `30`, `60`, `240`, and `1440`
                    (once a day). Use `0` to archive the feed (stop updating it). Omit the field
                    or send `null` to use your plan's default sync frequency (once a day on most
                    plans). Setting it requires a plan that supports per-feed sync frequency; on
                    other plans the request returns
                    `422` with `error.code = "sync_interval_not_available"`. On credit-metered API
                    plans an invalid value is rejected with `422` (`error.code = "invalid_sync_interval"`).
                    On credit-metered API plans each successful sync of a metered source consumes
                    API credits, so a faster interval consumes credits proportionally faster (an
                    hourly feed spends roughly 24x what a daily feed does); when the balance runs
                    out the feed's sources pause until it is topped up.
                  example: 60
      responses:
        "201":
          description: Feed created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Feed"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /feeds/{id}:
    get:
      operationId: getFeed
      tags: [Feeds]
      summary: Get feed details
      description: |
        Returns feed details. Use `?include=sources` to embed sources in the response.
      parameters:
        - $ref: "#/components/parameters/FeedId"
        - name: include
          in: query
          description: "Comma-separated list of relations to embed. Supported: `sources`"
          schema:
            type: string
            example: "sources"
      responses:
        "200":
          description: Feed details (with optional embedded sources)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/Feed"
                      - type: object
                        properties:
                          sources:
                            type: array
                            description: Present when `?include=sources`
                            items:
                              $ref: "#/components/schemas/Source"
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      operationId: updateFeed
      tags: [Feeds]
      summary: Update feed settings
      description: |
        Updates feed settings, including manual and AI moderation.

        **AI moderation** requires the Pro plan or higher. Enabling it
        (`ai_moderation` or any AI sub-filter) on an unsupported plan returns
        `422` with `error.code = "ai_moderation_not_available"` and an
        `error.action` of type `upgrade_plan` containing a magic link to the
        upgrade page.
      parameters:
        - $ref: "#/components/parameters/FeedId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                sync_interval:
                  type: integer
                  nullable: true
                  description: >
                    How often the feed's sources are synced, in minutes. Allowed values are the
                    standard sync frequencies `1`, `5`, `10`, `30`, `60`, `240`, and `1440`
                    (once a day). Use `0` to archive the feed (stop updating it). Send `null` or
                    an empty value to clear the override and fall back to your plan's default sync
                    frequency (once a day on most plans). Setting it requires a plan that supports
                    per-feed sync frequency; on other plans the request returns `422` with
                    `error.code = "sync_interval_not_available"`. On credit-metered API plans an
                    invalid value is rejected with `422` (`error.code = "invalid_sync_interval"`).
                    On credit-metered API plans each successful sync of a metered source consumes
                    API credits, so a faster interval consumes credits proportionally faster (an
                    hourly feed spends roughly 24x what a daily feed does); when the balance runs
                    out the feed's sources pause until it is topped up.
                queue:
                  type: boolean
                  description: Enable moderation queue
                allowed:
                  type: string
                  description: Comma-separated whitelist terms
                disallowed:
                  type: string
                  description: Comma-separated blacklist terms
                profanity:
                  type: boolean
                  description: Enable profanity filter
                prevent_duplicates:
                  type: boolean
                  description: Enable duplicate post prevention
                deduplication_strategy:
                  type: string
                  enum: [prioritize_top_source, distribute_across_sources]
                  description: >
                    Which post survives when duplicates are detected.
                    `prioritize_top_source` keeps the post from the highest-priority
                    source (lowest `position`). `distribute_across_sources` spreads
                    the survivors across the feed's sources, so no one source wins
                    every duplicate. It balances which source survives each set of
                    duplicates, not the feed's overall share per source.
                    Only meaningful when `prevent_duplicates` is true.
                ai_moderation:
                  type: boolean
                  description: >
                    Master switch for AI moderation. When enabled, new posts are
                    queued and evaluated by AI before going live. Requires at
                    least one AI sub-filter (`ai_harmful_content_filter`,
                    `ai_moderation_rules`, or `ai_sentiment_filter`) to take effect.
                    Requires the Pro plan or higher.
                ai_harmful_content_filter:
                  type: boolean
                  description: >
                    Check posts against the OpenAI moderation API and hold any
                    flagged for harmful content (hate, violence, sexual, etc.).
                ai_moderation_rules:
                  type: string
                  nullable: true
                  description: >
                    Natural-language rules the AI applies when deciding whether to
                    keep a post (e.g. "Only show posts about coffee").
                ai_sentiment_filter:
                  type: array
                  description: >
                    Allowed post sentiments. Posts whose AI-classified sentiment is
                    not in this list are held in the moderation queue. An empty
                    array disables sentiment filtering.
                  items:
                    type: string
                    enum: [positive, neutral, negative]
      responses:
        "200":
          description: Feed updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Feed"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

    delete:
      operationId: deleteFeed
      tags: [Feeds]
      summary: Delete a feed
      parameters:
        - $ref: "#/components/parameters/FeedId"
      responses:
        "204":
          description: Feed deleted
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Sources ─────────────────────────────────────────────────────
  /feeds/{feed_id}/sources:
    get:
      operationId: listSources
      tags: [Sources]
      summary: List sources for a feed
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: Sources listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Source"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"
        "404":
          $ref: "#/components/responses/NotFound"

    post:
      operationId: createSource
      tags: [Sources]
      summary: Add a source to a feed
      description: |
        Creates a new source and performs an initial sync to fetch posts.
        Some platforms (like LinkedIn) sync asynchronously — check `syncable` status after creation.

        Use `GET /platforms` to see available platforms and their supported term types.

        **Example — Reddit:** use `term_type: "channel"` with a subreddit name
        (or `reddit.com/r/...` URL) to pull a subreddit's posts, or
        `term_type: "mentions"` with any keyword to pull Reddit search results:

        ```json
        { "platform": "Reddit", "term": "aww", "term_type": "channel" }
        { "platform": "Reddit", "term": "your brand", "term_type": "mentions" }
        ```

        Returns `422` with `error.code = "source_limit_reached"` and an upgrade action
        if the feed's source limit is exceeded. Returns `422` with
        `error.code = "social_account_required"` and a connect action if the platform
        requires OAuth and no account is connected.
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform, term, term_type]
              properties:
                platform:
                  type: string
                  description: "Social media platform (use `GET /platforms` to see options)"
                  example: "Instagram"
                term:
                  type: string
                  description: Username, hashtag, or URL
                  example: "nasa"
                term_type:
                  type: string
                  description: "Type of search term (use `GET /platforms` to see valid types per platform)"
                  example: "username"
                options:
                  type: object
                  description: Platform-specific options
                  properties:
                    retweets:
                      type: string
                      description: Include retweets (Twitter)
                    replies:
                      type: string
                      description: Include replies (Twitter)
                    only_reels:
                      type: string
                      description: Only fetch reels (Instagram)
                    only_posts:
                      type: string
                      description: Only fetch posts (Instagram)
      responses:
        "201":
          description: Source created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Source"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /feeds/{feed_id}/sources/{id}:
    delete:
      operationId: deleteSource
      tags: [Sources]
      summary: Remove a source from a feed
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Source deleted
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Posts ────────────────────────────────────────────────────────
  /feeds/{feed_id}/posts:
    get:
      operationId: listFeedPosts
      tags: [Posts]
      summary: List posts from a feed
      description: |
        Returns paginated posts from a feed. Supports filtering by source, platform, date range, and text search.
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: status
          in: query
          description: Filter by moderation status
          schema:
            type: string
            enum: [public, moderated, rejected]
            default: public
        - name: filter
          in: query
          description: |
            Filter to specific sources, as a comma-separated list. Each entry is
            matched against the feed's sources by source term, platform name, or
            numeric source ID — so to filter by source, pass the source IDs here
            (e.g. `filter=12345,67890`). There is no separate `source_id` /
            `source_ids` / `sources` parameter.
          schema:
            type: string
            example: "12345,67890"
        - name: search
          in: query
          description: Text search across post message, poster name, and description
          schema:
            type: string
        - name: starting_at
          in: query
          description: Only posts after this date
          schema:
            type: string
            format: date-time
        - name: ending_at
          in: query
          description: Only posts before this date
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Posts listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/FeedPost"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"
        "404":
          $ref: "#/components/responses/NotFound"

  /feeds/{feed_id}/posts/{id}:
    get:
      operationId: getFeedPost
      tags: [Posts]
      summary: Get a single post
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/PostId"
      responses:
        "200":
          description: Post details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/FeedPost"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      operationId: deletePost
      tags: [Posts]
      summary: Delete a post
      description: Soft-deletes the post. It can be recovered via the approve endpoint.
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/PostId"
      responses:
        "204":
          description: Post deleted
        "404":
          $ref: "#/components/responses/NotFound"

  /feeds/{feed_id}/posts/{id}/approve:
    post:
      operationId: approvePost
      tags: [Posts]
      summary: Approve a moderated post
      description: Restores a moderated, deleted, or rejected post back to public.
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/PostId"
      responses:
        "200":
          description: Post approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/FeedPost"
        "422":
          description: Post is no longer available on the source platform (`post_unavailable`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"

  /feeds/{feed_id}/posts/{id}/reject:
    post:
      operationId: rejectPost
      tags: [Posts]
      summary: Reject a post
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/PostId"
      responses:
        "200":
          description: Post rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/FeedPost"
        "422":
          description: Post is no longer available on the source platform (`post_unavailable`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"

  /feeds/{feed_id}/posts/{id}/pin:
    post:
      operationId: pinPost
      tags: [Posts]
      summary: Pin a post to the top of the feed
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/PostId"
      responses:
        "200":
          description: Post pinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/FeedPost"
        "404":
          $ref: "#/components/responses/NotFound"

  /feeds/{feed_id}/posts/{id}/unpin:
    post:
      operationId: unpinPost
      tags: [Posts]
      summary: Unpin a post
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - $ref: "#/components/parameters/PostId"
      responses:
        "200":
          description: Post unpinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/FeedPost"
        "404":
          $ref: "#/components/responses/NotFound"

  /feeds/{feed_id}/posts/bulk:
    post:
      operationId: bulkModeratePosts
      tags: [Posts]
      summary: Bulk moderate posts
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action_type]
              properties:
                action_type:
                  type: string
                  enum: [approve, reject, soft_delete, hard_delete]
                post_ids:
                  type: array
                  items:
                    type: integer
                  description: Specific post IDs to moderate
                tab:
                  type: string
                  enum: [public, moderation_queue, trash]
                  description: Moderate all posts in this tab (alternative to post_ids)
                excluded_post_ids:
                  type: array
                  items:
                    type: integer
                  description: Post IDs to exclude when using tab
      responses:
        "204":
          description: Bulk moderation applied
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  # ── Embed ───────────────────────────────────────────────────────
  /feeds/{feed_id}/embed:
    get:
      operationId: getFeedEmbed
      tags: [Embed]
      summary: Get embed code snippets
      description: |
        Returns ready-to-use embed snippets for displaying the feed on a website.
        Includes JavaScript, iframe, WordPress shortcode, and WordPress PHP options.
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
      responses:
        "200":
          description: Embed snippets
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/EmbedSnippets"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Search ──────────────────────────────────────────────────────
  /search/posts:
    get:
      operationId: searchPosts
      tags: [Search]
      summary: Search posts across all feeds
      description: |
        Searches for posts matching a text query across all of the account's feeds.
        Use `+` between terms for AND matching, or `,` for OR matching. Spaces are
        stripped before matching.

        A literal `+` in a URL query string decodes to a space, so send it as `%2B`
        (`?query=product%2Blaunch`); most HTTP clients do this when you pass the
        query as a parameter rather than building the URL by hand.

        An account-wide search with no date bound (no `feed_ids`, `starting_at` or
        `ending_at`) covers the posts collected into your feeds in the last 90 days.
        A search that cannot complete in time returns `422 search_too_broad`; narrow
        it with `feed_ids`, a shorter date range or a more specific query.
      parameters:
        - name: query
          in: query
          required: true
          description: "Text to search for. Use `+` (URL-encoded as `%2B`) for AND, `,` for OR."
          schema:
            type: string
            example: "product%2Blaunch"
        - name: feed_ids
          in: query
          description: Comma-separated feed IDs to narrow the search scope
          schema:
            type: string
        - name: platforms
          in: query
          description: Comma-separated platform names to filter by
          schema:
            type: string
        - name: starting_at
          in: query
          description: Only posts published after this ISO-8601 date or datetime. An unparseable value returns `422 invalid_date`.
          schema:
            type: string
            format: date-time
        - name: ending_at
          in: query
          description: Only posts published before this ISO-8601 date or datetime. An unparseable value returns `422 invalid_date`.
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/FeedPost"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  # ── Analytics ───────────────────────────────────────────────────
  /feeds/{feed_id}/analytics:
    get:
      operationId: getFeedAnalytics
      tags: [Analytics]
      summary: Get feed analytics and engagement stats
      description: |
        Returns engagement metrics, source breakdown, and top posts for a feed
        within a date range. Defaults to the last 30 days.
      parameters:
        - $ref: "#/components/parameters/FeedIdPath"
        - name: start
          in: query
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
          schema:
            type: string
            format: date
        - name: end
          in: query
          description: End date (YYYY-MM-DD). Defaults to today.
          schema:
            type: string
            format: date
      responses:
        "200":
          description: Analytics data
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/FeedAnalytics"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Webhooks ────────────────────────────────────────────────────
  /webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhook subscriptions
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: Webhooks listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Webhook"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"

    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: |
        The `secret` is returned only in the creation response. Store it securely —
        it's used to verify webhook payloads via HMAC-SHA256 signature in the
        `X-Juicer-Signature` header.

        See the `WebhookDeliveryBody` schema for the shape your endpoint will
        receive, how to verify the signature, and how to dedup retries.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, subscribed_events]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS endpoint to receive events
                subscribed_events:
                  type: array
                  items:
                    type: string
                    enum: [post.created, social_account.expiring, social_account.expired]
      responses:
        "201":
          description: Webhook created (includes secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/WebhookWithSecret"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /webhooks/{id}:
    patch:
      operationId: updateWebhook
      tags: [Webhooks]
      summary: Update a webhook
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                subscribed_events:
                  type: array
                  items:
                    type: string
                status:
                  type: string
                  enum: [active, paused]
      responses:
        "200":
          description: Webhook updated
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Webhook deleted
        "404":
          $ref: "#/components/responses/NotFound"

  /webhooks/{id}/test:
    post:
      operationId: testWebhook
      tags: [Webhooks]
      summary: Send a test event to the webhook
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Test event created and delivery enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/WebhookEvent"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhook_events:
    get:
      operationId: listWebhookEvents
      tags: [Webhooks]
      summary: List webhook events
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: event_type
          in: query
          schema:
            type: string
            enum: [post.created, social_account.expiring, social_account.expired]
      responses:
        "200":
          description: Events listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEvent"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"

  /webhook_events/{id}:
    get:
      operationId: getWebhookEvent
      tags: [Webhooks]
      summary: Get event details with delivery history
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Event with deliveries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/WebhookEvent"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhooks/{webhook_id}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      summary: List deliveries for a webhook
      parameters:
        - name: webhook_id
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: Deliveries listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookDelivery"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhooks/{webhook_id}/deliveries/{id}/redeliver:
    post:
      operationId: redeliverWebhookDelivery
      tags: [Webhooks]
      summary: Replay a failed delivery
      description: |
        Queues a fresh delivery of the same event to the same endpoint, with its
        own full run of automatic retries — use it once a delivery has exhausted
        the retries Juicer makes on its own. The replay is a new delivery row
        starting at `attempt_number` 1; the event `id` your endpoint receives is
        unchanged, so a receiver that already processed it dedups as usual.

        A `disabled` webhook accepts the request but never sends the delivery.
      parameters:
        - name: webhook_id
          in: path
          required: true
          schema:
            type: integer
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "201":
          description: New delivery enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/WebhookDelivery"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Data API ────────────────────────────────────────────────────
  /data/posts:
    get:
      operationId: getDataPosts
      tags: [Data API]
      summary: Look up posts for a handle, hashtag, or other term
      description: |
        Returns recent posts for the given term across the requested platforms.
        Availability and coverage vary by platform; results are returned on a
        best-effort basis.

        **Term type — explicit or auto-detected.** Pass `term_type` to choose how
        the term is interpreted. If omitted, it is auto-detected:
        - Terms starting with `#` → `hashtag` (e.g. `#travel`)
        - Terms starting with `@` or with no prefix → `username` (e.g. `@nasa` or `nasa`)

        Some platforms only accept term types that can't be auto-detected and so
        **require** an explicit `term_type` — e.g. Reddit (`channel` for a
        subreddit, `mentions` for a keyword search). See the `term_type` parameter
        for the supported value per platform.

        **Supported platforms and term types** (this endpoint serves only
        connection-free lookups, a subset of `GET /platforms`):

        | Platform | term_type values |
        | --- | --- |
        | Bluesky | `username`, `hashtag` |
        | Twitter (X) | `username`, `hashtag`, `mentions` |
        | YouTube | `username`, `channel`, `hashtag` |
        | Vimeo | `username` |
        | Facebook | `username`, `hashtag` |
        | Instagram | `username`, `hashtag` |
        | Tumblr | `username`, `hashtag` |
        | Pinterest | `username`, `hashtag` |
        | Flickr | `username` |
        | Giphy | `username` |
        | LinkedIn | `username`, `hashtag` |
        | Reddit | `channel`, `mentions` |
        | TikTok | `username`, `hashtag`, `mentions` |
        | Google | `reviews` |

        Google Reviews are fetched by Google Place ID (the `term`), not a handle,
        and the Place ID can't be auto-detected — so Google **requires** an
        explicit `term_type=reviews`. Resolve a business name to a Place ID first
        via `GET /data/profiles` (a Google place search), then pass that Place ID
        back here as the `term`.

        A platform not listed here, or an unsupported `term_type` for a listed
        platform, comes back in `meta.platforms` with an error — other platforms
        in the same request still return.

        **Media.** A post's `media` contains only media natively attached to the
        post (images, videos, or a thumbnail the source platform itself supplies
        for a shared article). This endpoint does not fetch a link-preview (Open
        Graph) image from the linked page, so a post that is only a link with no
        platform-supplied thumbnail has empty `media`. The link is in `message`.

        **Pagination.** Each call returns one upstream page per platform (the page
        size is whatever the platform's API returns — it varies by platform and is
        not configurable; there is no `limit`/`per_page` param). Every successful
        `meta.platforms[]` entry includes a `next_cursor` and `has_more`. To fetch
        the next page, repeat the same request (same `term`/`platforms`/`term_type`)
        and add the `cursor` param with the `next_cursor` value(s) you received.
        Pagination is per-platform: in a multi-platform request, pass a
        comma-separated list of the cursors for the platforms you want to continue;
        platforms whose `next_cursor` was `null` have no more results. Cursors are
        opaque — do not parse or construct them.
      parameters:
        - name: term
          in: query
          required: true
          description: The term to look up (handle, hashtag, or keyword), interpreted per `term_type`
          schema:
            type: string
            example: "@nasa"
        - name: platforms
          in: query
          required: true
          description: |
            Comma-separated list of platforms to query. See the table above for
            the platforms this endpoint supports.
          schema:
            type: string
            example: "Instagram,YouTube"
        - name: term_type
          in: query
          required: false
          description: |
            How to interpret `term`. When omitted it is auto-detected
            (`#`-prefixed → `hashtag`, otherwise `username`). Applied to every
            requested platform; a platform that doesn't support the given value
            returns an `invalid_term_type` error in `meta.platforms`. See the
            table above for valid values per platform.
          schema:
            type: string
            enum: [username, hashtag, channel, mentions, reviews]
            example: "channel"
        - name: cursor
          in: query
          required: false
          description: |
            Opaque pagination cursor(s) from a prior response's
            `meta.platforms[].next_cursor`. Comma-separate multiple cursors to
            continue several platforms at once. Each cursor encodes its own
            platform; cursors are matched to the platforms named in `platforms`,
            and a malformed cursor is reported as an `invalid_cursor` entry in
            `meta.platforms` without affecting the others. Omit on the first page.
          schema:
            type: string
      responses:
        "200":
          description: Posts fetched successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DataPostsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /data/profiles:
    get:
      operationId: getDataProfiles
      tags: [Data API]
      summary: Look up the canonical profile per platform for a handle
      description: |
        Resolves one or more handles to their canonical profile on each
        requested platform. Pass several handles as a comma-separated `term`
        list — every term is looked up on every platform independently, and
        each `data[]` entry is a `{ platform, term, exact_match, profiles[] }`
        group. Hashtag terms (`#foo`) are rejected.

        Every profile object includes every field and every `metrics` key;
        any value not available for a given account is `null`. Do not assume a
        field is always present — coverage varies by platform and account.

        When a platform has an exact handle match, `profiles` holds that one
        account (`exact_match: true`). Otherwise it holds best-effort candidate
        matches for you to disambiguate (`exact_match: false`).

        If one platform fails or has no result, others still return. Check
        `meta.platforms` for per-platform status.
      parameters:
        - name: term
          in: query
          required: true
          description: |
            One or more handles to resolve, comma-separated (no `#` hashtag
            prefix; `@` prefix allowed and stripped). Each handle is looked up
            on every requested platform. Up to 5 handles per request.
          schema:
            type: string
            example: "juicer,juicerio,juicer.io"
        - name: platforms
          in: query
          required: true
          description: |
            Comma-separated list of platforms. Supported: Bluesky, YouTube,
            LinkedIn, Pinterest, Twitter (X), Instagram, Facebook, TikTok,
            Google. Unsupported names appear in `meta.platforms` with
            `error_code = "unknown_platform"`.
          schema:
            type: string
            example: "Bluesky,YouTube,LinkedIn"
      responses:
        "200":
          description: Profiles fetched successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DataProfilesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  # ── Users ───────────────────────────────────────────────────────
  /users:
    get:
      operationId: listUsers
      tags: [Users]
      summary: List account users
      description: |
        Returns managers and collaborators on the current account plus any users
        with pending admin invitations. Requires an API key belonging to an
        account owner or manager.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: Users listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/User"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not an account owner or manager
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      operationId: createUser
      tags: [Users]
      summary: Invite a manager or collaborator
      description: |
        Invites a new user to the current account. Requires an API key belonging
        to an account owner or manager.

        **Managers** receive a Devise admin invitation. They may only be invited
        if they are on the free plan with no feeds and no existing enterprise
        account. `feed_ids` is ignored for managers — managers gain access to all
        feeds on the account once they accept the invitation.

        **Collaborators** can be invited with or without feeds. When `feed_ids`
        is provided, each ID must belong to the current account and the collaborator
        is assigned to those feeds (first feed triggers a "new feed" email for
        existing users; new users receive a Devise invitation). When `feed_ids`
        is omitted or empty, the collaborator is pre-provisioned with no
        assignments — feeds can be attached later via
        `PUT /v1/users/{user_id}/feeds/{feed_id}`.

        Inviting users (either role) requires the **Enterprise** or **Team**
        plan. Accounts on other plans receive `422` with
        `error.code = "user_limit_reached"` and an `error.action` of type
        `upgrade_plan` containing a magic link to the upgrade page.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserCreateRequest"
      responses:
        "201":
          description: User invited
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/User"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not an account owner or manager
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /users/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getUser
      tags: [Users]
      summary: Fetch a single user
      responses:
        "200":
          description: User fetched
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/User"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not an account owner or manager
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteUser
      tags: [Users]
      summary: Remove a user from the account
      description: |
        Removes the user from this account. If the user has a pending admin
        invitation, the invitation is cleared. Otherwise the user is downgraded
        to a personal free plan and all of their feed assignments are removed.

        **What happens to the user's social account connections**

        - A connection that another member of this account reconnected most
          recently is transferred to that member. The token already belongs
          to them, so nothing changes for the feeds using it.
        - Every other connection leaves the account together with the user.

        **When feeds depend on a leaving connection**

        If any feed still syncs through a connection that would leave, the
        request is rejected with `409 user_has_connections_in_use` and
        `error.details.social_accounts` lists the affected connections and
        feeds. Nothing is changed. You then have two options:

        1. Ask a member who stays on the account to reconnect those social
           accounts, then repeat the request.
        2. Repeat the request with `force=true`. The user is removed and the
           listed feeds stop updating until someone on the account reconnects
           them.

        The account owner cannot be deleted, and a caller cannot delete themselves.
      parameters:
        - name: force
          in: query
          required: false
          description: Remove the user even if feeds still sync on connections they own.
          schema:
            type: boolean
            default: false
      responses:
        "204":
          description: User removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Cannot remove the owner, cannot remove self, or caller is not an admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            Feeds still sync on connections the user owns (`user_has_connections_in_use`).
            `error.details.social_accounts` lists them. Nothing was changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: user_has_connections_in_use
                  message: Feeds still sync on connections this user owns, and they stop updating if the user is removed. Have someone who stays on the account reconnect them first, or repeat the request with force=true.
                  details:
                    social_accounts:
                      - id: 4821
                        provider: instagram_login
                        name: acme
                        feed_names: [Acme homepage, Acme careers]

  /users/{user_id}/feeds/{feed_id}:
    parameters:
      - name: user_id
        in: path
        required: true
        schema:
          type: integer
      - name: feed_id
        in: path
        required: true
        schema:
          type: integer
    put:
      operationId: assignUserFeed
      tags: [Users]
      summary: Assign a feed to a user
      description: |
        Creates an assignment linking the user to the given feed. The feed must
        belong to the current account. Subject to the user's plan feed limit.
        Idempotent — returns `200` when the assignment already exists.
      responses:
        "200":
          description: User was already assigned to this feed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/User"
        "201":
          description: Feed assigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/User"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not an account owner or manager
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      operationId: unassignUserFeed
      tags: [Users]
      summary: Remove a feed assignment from a user
      description: |
        Removes the assignment linking the user to the feed. If this would
        leave an enterprise/team user with zero feeds, the call fails with
        `422 last_feed_removal_blocked` — delete the user instead. If the feed
        has no remaining assignments after removal, the feed is soft-deleted.
      responses:
        "204":
          description: Assignment removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not an account owner or manager
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /users/{id}/sign_in_token:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    post:
      operationId: createUserSignInToken
      tags: [Users]
      summary: Create a sign-in token for a collaborator
      description: |
        Mints a short-lived, single-use **sign-in token** for a **collaborator**
        in the account, so you can drop that collaborator straight into their
        Juicer dashboard without showing the login screen — for example, when
        embedding the dashboard in an iframe on your own site.

        The returned `sign_in_url` signs the user in and redirects to their
        dashboard. It is built against your account's white-label host when one
        is configured, so it works as an iframe `src` on your site. The token
        expires in 30 minutes and can only be used once.

        Pass an optional `feed_id` to land the user directly on that feed's
        editor instead of the dashboard home — useful right after creating a
        feed for the collaborator. The feed must be one the collaborator is
        assigned to, otherwise the call returns `404`.

        Requires an account owner or manager API key. The target user must be a
        collaborator in the current account; minting a token for an owner or
        manager returns `422`.

        This endpoint is only available when sign-in tokens are enabled for your
        account; otherwise it returns `403`.

        **Token reuse is keyed on the destination.** If the collaborator already
        has an unconsumed token for the same destination, you normally get that
        same token back rather than a new one; concurrent calls for the same
        destination may still mint separate tokens. The dashboard home and a
        specific `feed_id` are different destinations, so a request for each one
        returns a different token.

        Expiry alone does not retire a token. A token that has expired but was
        never opened is revived rather than replaced: the next call returns that
        same token with a fresh 30-minute window. A token that is still valid is
        returned unchanged, so its `expires_at` is the time left on the original
        window rather than a fresh 30 minutes. A genuinely new token is minted
        only once the previous one has been consumed.

        Generate the token server-side at page-load time. Never persist it, share
        it between users, or write it to logs. For its 30-minute window the URL
        grants immediate access to the collaborator's dashboard.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                feed_id:
                  type: integer
                  description: |
                    Optional. When provided, the sign-in URL lands the user on
                    this feed's editor instead of the dashboard home. Must be a
                    feed the collaborator is assigned to.
      responses:
        "201":
          description: Sign-in token created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/SignInToken"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Returned in three cases. All carry `"code": "forbidden"`, so the
            message is what tells them apart:

            - `API v1 is not enabled for your account.` when the account has no
              API access at all. Checked before anything endpoint-specific, so
              it takes precedence over both cases below.
            - `Admin access required.` when the API key belongs to a
              collaborator rather than an account owner or manager. This is
              checked before the target user is looked up, so it takes
              precedence over a `404` for an unknown user ID.
            - `Sign-in tokens are not enabled for your account.` when the caller
              is an owner or manager but the account does not have the sign-in
              token entitlement. Checked after the target user is resolved, so
              an unknown user ID still returns `404` and a non-collaborator
              target still returns `422`, even on an account without the
              entitlement.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            The target user is not a collaborator (`invalid_target_role`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

# OpenAPI 3.1 webhooks: describes the requests Juicer sends *to* you, as opposed
# to `paths`, which describes requests you send to Juicer. This is also what makes
# WebhookDeliveryBody render in generated docs.
webhooks:
  postCreated:
    post:
      operationId: onPostCreated
      tags: [Webhooks]
      summary: New posts were ingested by a sync
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookDeliveryBody"
      responses:
        "200":
          description: |
            Return any 2xx to acknowledge. Any other response, a timeout, or a
            connection error fails the attempt and is retried up to 5 times with
            growing backoff; see `WebhookDeliveryBody` for the full contract.

  socialAccountExpiring:
    post:
      operationId: onSocialAccountExpiring
      tags: [Webhooks]
      summary: A connected social account's token expires within five days
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookDeliveryBody"
      responses:
        "200":
          description: |
            Return any 2xx to acknowledge. Any other response, a timeout, or a
            connection error fails the attempt and is retried up to 5 times with
            growing backoff; see `WebhookDeliveryBody` for the full contract.

  socialAccountExpired:
    post:
      operationId: onSocialAccountExpired
      tags: [Webhooks]
      summary: A connected social account's token has expired
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookDeliveryBody"
      responses:
        "200":
          description: |
            Return any 2xx to acknowledge. Any other response, a timeout, or a
            connection error fails the attempt and is retried up to 5 times with
            growing backoff; see `WebhookDeliveryBody` for the full contract.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API key prefixed with `jcr_`. To obtain a key programmatically
        (no dashboard required), see `POST /v1/authorize`.

  parameters:
    Page:
      name: page
      in: query
      schema:
        type: integer
        default: 1
    PerPage:
      name: per_page
      in: query
      schema:
        type: integer
        default: 25
        maximum: 100
    FeedId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    FeedIdPath:
      name: feed_id
      in: path
      required: true
      schema:
        type: integer
    PostId:
      name: id
      in: path
      required: true
      schema:
        type: integer

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    UnprocessableEntity:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Account:
      type: object
      properties:
        id:
          type: integer
        plan:
          type: string
          description: Internal plan name
        plan_display_name:
          type: string
          description: Human-readable plan name
        billing_cycle:
          type: object
          properties:
            start:
              type: string
              format: date
            end:
              type: string
              format: date
        usage:
          type: object
          properties:
            views:
              type: object
              properties:
                current:
                  type: integer
                limit:
                  type: integer
                  nullable: true
            feeds:
              type: object
              properties:
                current:
                  type: integer
                  description: >
                    Feeds counted toward the feed plan. On an account with a paid API plan, feeds
                    created through this API are funded by that plan and are not counted here.
                limit:
                  type: integer
            sources_per_feed_limit:
              type: integer
        features:
          type: object
          properties:
            api_enabled:
              type: boolean
            moderation:
              type: boolean
            mentions:
              type: boolean
        api_plan:
          type: string
          nullable: true
          description: API credit tier (`sandbox`, `launch`, `builder`, `scale`, `platform_partner`, `comped`); null when the account has no API access.
          example: "sandbox"
        api_plan_display_name:
          type: string
          nullable: true
          example: "Sandbox"
        credits:
          type: object
          nullable: true
          description: |
            API credit balance. Every Data API call (`GET /data/posts`, `GET /data/profiles`) is
            debited per platform using `cost_per_call`; a call the balance cannot cover returns
            `402 insufficient_credits`. The free Sandbox tier is a one-time 30-credit grant that
            never refills (`sandbox: true`, `refills_on: null`). Most Sandbox accounts can also buy credits
            pay-as-you-go, without a subscription (accounts billed by Juicer support buy through support);
            those show in `top_up_balance`. Null when the account has no API plan.
          properties:
            balance:
              type: integer
              description: Credits available right now across all active lots
              example: 30
            allowance_total:
              type: integer
              description: Size of the current plan allowance (or the Sandbox grant)
              example: 30
            allowance_remaining:
              type: integer
              example: 30
            top_up_balance:
              type: integer
              description: Credits bought as top-ups. Lots are spent soonest-expiring first, so top-ups usually go after a paid plan's monthly allowance and always before the never-expiring Sandbox grant.
              example: 0
            refills_on:
              type: string
              format: date
              nullable: true
              description: Date the paid allowance is replaced; null for Sandbox and for a cancelling plan
              example: null
            sandbox:
              type: boolean
              example: true
            cost_per_call:
              type: object
              description: Credits debited per platform per Data API call; platforms not listed cost `default`
              additionalProperties:
                type: integer
              example: { "Facebook": 6, "LinkedIn": 3, "default": 1 }
        created_at:
          type: string
          format: date-time

    Platform:
      type: object
      properties:
        platform:
          type: string
          description: Internal platform identifier (use this when creating a source)
          example: "Instagram"
        display_name:
          type: string
          description: Human-readable name
          example: "Instagram"
        term_types:
          type: array
          items:
            $ref: "#/components/schemas/PlatformTermType"

    PlatformTermType:
      type: object
      properties:
        term_type:
          type: string
          description: The term type value to use when creating a source
          example: "hashtag"
        display_name:
          type: string
          description: Human-readable label for the term type (e.g. "Instagram Hashtag", "Facebook Page").
          example: "Instagram Hashtag"
        data_api:
          type: boolean
          description: |
            True when this term type can be queried with `GET /data/posts` without a feed or a
            connected social account. False term types are Integration-API source types only.
          example: true
        requires_connection:
          type: object
          nullable: true
          description: |
            Present when this term type requires a connected social account for the caller's
            account. Whether a connection is needed is account-aware and depends on the
            account's configuration; callers should rely on the presence or absence of this
            field rather than assuming a fixed per-platform requirement.
          properties:
            provider:
              type: string
              description: OAuth provider to connect (use this with `POST /social_accounts/connect_url`)
              example: "facebook"
            description:
              type: string
              description: Human-readable explanation of what connection is needed
              example: "Requires a Facebook account with an Instagram Business Account connected."

    SocialAccount:
      type: object
      properties:
        id:
          type: integer
        provider:
          type: string
          description: OAuth provider identifier
          example: "facebook"
        name:
          type: string
          description: Account name from the provider
        platforms:
          type: array
          description: Source platforms this account enables
          items:
            type: string
          example: ["Facebook", "Instagram"]
        expired:
          type: boolean
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: >-
            Best estimate of when this connection's stored credential stops working, or null when no
            estimate is recorded (which does not imply the token never expires — some older facebook
            connections have a null value). What it reflects varies by provider and is not always the
            access token's true expiry: for facebook it is a Juicer-set fallback roughly 60 days from
            the last refresh; for tiktok it is the refresh token's lifetime (~365 days), not the
            shorter access token's; for login-only connections such as google_oauth2 it is typically
            about an hour after sign-in. A value in the past does not pinpoint when the feed broke:
            a revoked or invalidated credential is only detected on a later sync, and the timestamp is
            set at detection, so the connection may have stopped working earlier. Read `expired` for
            the current known state.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        instagram_business_accounts:
          type: array
          description: Present only for Facebook provider accounts
          items:
            type: object
            properties:
              id:
                type: integer
              username:
                type: string

    PlatformConnectionStatus:
      type: object
      properties:
        platform:
          type: string
          description: Source platform name
          example: "Instagram"
        provider:
          type: string
          description: OAuth provider to connect (use with `POST /social_accounts/connect_url`)
          example: "instagram_login"
        connected:
          type: boolean
          description: Whether a valid (non-expired) account is connected
        allows_multiple:
          type: boolean
          description: Whether multiple accounts can be connected for this provider

    Feed:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        sources_count:
          type: integer
        created_via_api:
          type: boolean
          readOnly: true
          description: >
            `true` when the feed was created through this API. On an account with a paid API plan such
            feeds are funded by that plan and left out of `usage.feeds.current`.
        sync_interval:
          type: integer
          nullable: true
          description: >
            The per-feed sync frequency override in minutes, or `null` when the feed uses its
            plan's default. `0` means the feed is archived and not updated.
        update_frequency:
          type: integer
          nullable: true
          description: >
            The effective sync frequency in minutes applied to the feed: the `sync_interval`
            override when one is set, otherwise the plan's default. `null` when the plan has no
            default frequency configured.
        moderation:
          type: object
          properties:
            enabled:
              type: boolean
            allowed:
              type: string
              nullable: true
            disallowed:
              type: string
              nullable: true
            profanity_filter:
              type: boolean
            prevent_duplicates:
              type: boolean
            deduplication_strategy:
              type: string
              enum: [prioritize_top_source, distribute_across_sources]
            ai_moderation_available:
              type: boolean
              description: Whether the account's plan supports AI moderation (Pro or higher).
            ai_moderation:
              type: boolean
              description: Whether AI moderation is enabled for this feed.
            ai_harmful_content_filter:
              type: boolean
              description: Whether the OpenAI harmful-content check is enabled.
            ai_moderation_rules:
              type: string
              nullable: true
              description: Natural-language AI moderation rules, or null if unset.
            ai_sentiment_filter:
              type: array
              description: Allowed post sentiments. Empty when sentiment filtering is off.
              items:
                type: string
                enum: [positive, neutral, negative]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    Source:
      type: object
      properties:
        id:
          type: integer
        platform:
          type: string
          description: Human-readable platform name
        term:
          type: string
        term_type:
          type: string
        name:
          type: string
          nullable: true
        options:
          type: object
        syncable:
          type: boolean
        unsyncable_reason:
          type: string
          nullable: true
        synced_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    FeedPost:
      type: object
      properties:
        id:
          type: integer
        external_id:
          type: string
        platform:
          type: string
        url:
          type: string
          nullable: true
        message:
          type: string
          nullable: true
        external_created_at:
          type: string
          format: date-time
        poster:
          $ref: "#/components/schemas/Poster"
        media:
          type: array
          items:
            $ref: "#/components/schemas/Medium"
        like_count:
          type: integer
          description: >
            Like/reaction count. Present for every platform that has a concept
            of engagement. Omitted entirely for platforms that report none
            (RSS/blog feeds, Google reviews).
        comment_count:
          type: integer
          description: >
            Comment/reply count. Present for every platform that has a concept
            of engagement. Omitted entirely for platforms that report none
            (RSS/blog feeds, Google reviews).
        share_count:
          type: integer
          description: >
            Public share/repost count. Present only for platforms that collect
            it (Facebook, TikTok, X, Bluesky). Omitted entirely for other
            platforms.
        view_count:
          type: integer
          description: >
            Video view count. Present only for YouTube and TikTok. Omitted for
            platforms that don't collect it.
        quote_count:
          type: integer
          description: >
            Number of times the post has been quoted. Present only for X and
            Bluesky. Omitted for platforms that don't collect it.
        bookmark_count:
          type: integer
          description: >
            Bookmark count. Present only for X. Omitted for platforms that
            don't collect it.
        impression_count:
          type: integer
          description: >
            Impression count. Present only for X. Omitted for platforms that
            don't collect it.
        tagged_users:
          type: string
          nullable: true
        moderation_status:
          type: string
          enum: [public, moderated, deleted, rejected, unavailable]
          description: |
            - `public` — visible in the feed
            - `moderated` — held in moderation queue (auto-filtered by profanity, text filter, AI moderation, etc.)
            - `deleted` — manually deleted by a user
            - `rejected` — explicitly rejected. May also have been removed by the source
              platform, in which case approving or rejecting it returns `post_unavailable`.
            - `unavailable` — removed by the source platform, or its account became private.
              Not returned by any `status` filter, and cannot be approved or rejected; it
              returns on its own if the platform serves the post again.
        ai_sentiment:
          type: string
          nullable: true
          enum: [positive, neutral, negative, none]
          description: >
            AI-classified sentiment of the post. Populated only when AI moderation
            ran for this post; null otherwise.
        ai_decision_reason:
          type: string
          nullable: true
          enum: [harmful_content, custom_rule, sentiment, multiple]
          description: >
            Why AI moderation held this post: `harmful_content` (OpenAI flag),
            `custom_rule` (failed an `ai_moderation_rules` rule), `sentiment`
            (sentiment not in `ai_sentiment_filter`), or `multiple`. Null when AI
            moderation did not act on the post.
        ai_moderation_reasoning:
          type: string
          nullable: true
          description: >
            Free-text AI explanation for the moderation decision. Null when AI
            moderation did not act on the post.
        pinned:
          type: boolean
        referenced_post:
          nullable: true
          $ref: "#/components/schemas/ReferencedPost"
        reshared_by:
          nullable: true
          $ref: "#/components/schemas/Poster"
        created_at:
          type: string
          format: date-time

    EmbedSnippets:
      type: object
      properties:
        feed_url:
          type: string
          description: Public URL to the feed on juicer.io
        javascript:
          type: string
          description: Script tag to embed the feed on any website
          example: "<script type=\"text/javascript\" src=\"https://www.juicer.io/embed/my-feed/embed-code.js\" async defer></script>"
        iframe:
          type: string
          description: iframe tag for platforms that restrict JavaScript
        wordpress_shortcode:
          type: string
          description: WordPress shortcode for use in posts and pages
          example: "[juicer name='my-feed']"
        wordpress_php:
          type: string
          description: WordPress PHP function for use in theme files
          example: "<?php juicer_feed('name=my-feed'); ?>"

    FeedAnalytics:
      type: object
      properties:
        period:
          type: object
          properties:
            start:
              type: string
              format: date
            end:
              type: string
              format: date
        summary:
          type: object
          properties:
            total_posts:
              type: integer
            total_likes:
              type: integer
            total_comments:
              type: integer
            total_engagement:
              type: integer
              description: Sum of likes and comments
            page_views:
              type: integer
              description: Feed embed page views in the period
            clicks:
              type: integer
              description: Post clicks in the period
        sources:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              platform:
                type: string
              term:
                type: string
              posts_count:
                type: integer
              total_likes:
                type: integer
              total_comments:
                type: integer
        top_posts:
          type: array
          description: Top 10 posts by engagement (likes + comments)
          items:
            $ref: "#/components/schemas/FeedPost"

    Webhook:
      type: object
      properties:
        id:
          type: integer
        url:
          type: string
        subscribed_events:
          type: array
          items:
            type: string
        status:
          type: string
          enum: [active, paused, disabled]
        consecutive_failures:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookWithSecret:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          properties:
            secret:
              type: string
              description: HMAC-SHA256 signing secret (shown only on creation)

    WebhookDeliveryBody:
      type: object
      description: |
        The JSON body POSTed to your endpoint.

        Test deliveries from `POST /webhooks/{id}/test` use this same envelope,
        but their `data` is a fixed `{test: true, …}` object rather than real
        event data, and their `event` is whichever event you subscribed to first.
        Branch on `data.test` before treating `data` as an event payload.

        **Deliveries are at-least-once.** Dedup on `id`, which is stable across
        every attempt and every replay of an event. Do not use the
        `X-Juicer-Delivery-Id` header for this — it is regenerated on every
        attempt, so a receiver that processed a delivery but answered too slowly
        will see the retry under a different value. The same `id` also arrives
        at every webhook you have subscribed to that event, once per endpoint.

        **Failed deliveries are retried automatically.** A non-2xx response, a
        timeout, or a connection error fails that attempt, and the next one is
        scheduled 30s, then 2m, 8m, 32m and 2h later — six attempts in all,
        spread over roughly 2h45m. Answer within 10 seconds — 30 for
        `post.created`, whose body carries a whole sync batch — and do your
        processing after responding. Every attempt is its own row in
        `GET /webhooks/{webhook_id}/deliveries`, carrying its own `http_status`
        and `error_message`, and `attempt_number` counts attempts within one
        delivery: a new `pending` row means the next attempt is queued, and
        `failed` on attempt 6 means Juicer has given up.

        Once the retries are exhausted, replay the event yourself with
        `POST /webhooks/{webhook_id}/deliveries/{id}/redeliver`. That queues a
        fresh delivery of the same event under the same `id`, with its own full
        run of automatic retries.

        Only a delivery that exhausts its retries counts against the webhook's
        health, so a single flaky endpoint burns one failure, not six. After 10
        consecutive failed deliveries the webhook's status flips to `disabled`
        and deliveries stop — including redeliveries, which are accepted but
        never sent — until you re-enable it with `PATCH /webhooks/{id}`
        (`status: active`). Re-enabling resets `consecutive_failures` to 0, so a
        recovered endpoint starts over with the full budget. A successful
        delivery resets it too.

        Verify authenticity by computing HMAC-SHA256 over the **raw request body**
        with your webhook's `secret` and comparing to `X-Juicer-Signature`
        (formatted `sha256=<hex>`). Parsing and re-serializing the JSON before
        hashing will not match.
      required: [id, event, occurred_at, data]
      properties:
        id:
          type: integer
          description: |
            The webhook event's id, and your idempotency key. Retrievable via
            `GET /webhook_events/{id}`.
        event:
          type: string
          enum: [post.created, social_account.expiring, social_account.expired]
          description: Also sent in the `X-Juicer-Event` header.
        occurred_at:
          type: string
          format: date-time
          description: When the event was recorded, in UTC.
        data:
          oneOf:
            - $ref: "#/components/schemas/PostCreatedData"
            - $ref: "#/components/schemas/SocialAccountExpiryData"
            - $ref: "#/components/schemas/WebhookTestData"

    WebhookTestData:
      type: object
      description: |
        Sent only by `POST /webhooks/{id}/test`, under whichever event you
        subscribed to first. Present so you can exercise your endpoint and
        signature verification before any real event fires.
      required: [test, message, timestamp]
      properties:
        test:
          type: boolean
          const: true
          description: Always true. Use this to tell a test delivery from a real one.
        message:
          type: string
        timestamp:
          type: string
          format: date-time

    PostCreatedData:
      type: object
      description: |
        Sent for `post.created`. One event covers a whole sync, so `posts` holds
        every post that sync newly created — a source's first sync commonly
        carries tens of posts. Posts the sync merely updated (refreshed
        engagement counts, edited captions) are not included.

        Posts held for moderation are included; check `moderation_status` if you
        only want ones visible in the feed.

        **`moderation_status` is the state at ingestion, not a settled verdict.**
        A post queued for AI moderation is delivered as `moderated` and is
        commonly approved a few seconds later; a human can approve or reject at
        any time after that. Neither sends another event — `post.created` is the
        only post event Juicer emits. A subscriber that keeps only `public`
        posts from this payload therefore drops posts that did become public, so
        re-read them through `GET /feeds/{feed_id}/posts` for the settled status.
      required: [posts, feed, source]
      properties:
        posts:
          type: array
          items:
            $ref: "#/components/schemas/FeedPost"
        feed:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
        source:
          type: object
          properties:
            id:
              type: integer
            platform:
              type: string

    SocialAccountExpiryData:
      type: object
      description: |
        Sent for `social_account.expiring` (the token expires within five days)
        and `social_account.expired` (it already has). Each fires at most once per
        token; reconnecting or refreshing the connection arms them again.

        **Do not treat `expiring` as a guaranteed precursor to `expired`.** A
        connection that breaks outright skips straight to `expired`, and so can
        one that lapses between two scans.

        Timing: when a sync discovers a dead token, `expired` is sent
        immediately. Tokens that simply lapse are picked up by the next hourly
        scan. That scan only considers tokens that died within the last seven
        days, so a webhook registered long after a token died receives nothing
        for it.

        Connections whose provider issues no expiry date, such as Twitter, only
        ever produce `social_account.expired`, and only once a sync discovers the
        token is dead.
      required: [social_account]
      properties:
        social_account:
          $ref: "#/components/schemas/SocialAccount"

    WebhookEvent:
      type: object
      description: |
        An event as returned by the API. Note the field names differ from the
        delivered webhook body: `event_type` is `event` there, and `payload` is
        the `data` member. See `WebhookDeliveryBody`.
      properties:
        id:
          type: integer
          description: |
            Also sent as `id` in the delivered body, where it is the idempotency
            key. Use it to look this event up and reconcile a delivery.
        event_type:
          type: string
          enum: [post.created, social_account.expiring, social_account.expired]
        payload:
          description: |
            The event's data. Delivered as the `data` member of the webhook body,
            not as the whole body.
          oneOf:
            - $ref: "#/components/schemas/PostCreatedData"
            - $ref: "#/components/schemas/SocialAccountExpiryData"
            - $ref: "#/components/schemas/WebhookTestData"
        resource_type:
          type: string
          nullable: true
        resource_id:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        deliveries:
          type: array
          description: Present only on single event endpoint
          items:
            $ref: "#/components/schemas/WebhookDelivery"

    WebhookDelivery:
      type: object
      properties:
        id:
          type: integer
        webhook_id:
          type: integer
        webhook_event_id:
          type: integer
        status:
          type: string
          enum: [pending, success, failed]
          description: |
            `pending` while this attempt is queued or in flight. `failed` is
            final only on attempt 6 — below that, the retry arrives as a new
            `pending` row for the same event.
        http_status:
          type: integer
          nullable: true
        response_body:
          type: string
          nullable: true
        error_message:
          type: string
          nullable: true
        attempt_number:
          type: integer
          description: |
            Which attempt of this delivery it is, 1 through 6. A redelivery you
            trigger starts a new delivery at 1.
        duration_ms:
          type: integer
          nullable: true
        attempted_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    PaginationMeta:
      type: object
      properties:
        page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer

    # ── Data API schemas ─────────────────────────────────────────
    DataPostsResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Post"
        meta:
          type: object
          required: [total_results, platforms]
          properties:
            total_results:
              type: integer
            platforms:
              type: array
              items:
                $ref: "#/components/schemas/PlatformStatus"

    DataProfilesResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/DataProfileGroup"
        meta:
          type: object
          required: [total_results, platforms]
          properties:
            total_results:
              type: integer
              description: Total profiles across all platform groups.
            platforms:
              type: array
              description: One entry per (platform, term) pair that was looked up.
              items:
                $ref: "#/components/schemas/ProfilePlatformStatus"

    DataProfileGroup:
      type: object
      description: Profiles resolved for a single (platform, term) pair.
      required: [platform, term, exact_match, profiles]
      properties:
        platform:
          type: string
        term:
          type: string
          description: The handle this group was resolved for (one of the comma-separated `term` values).
        exact_match:
          type: boolean
          description: |
            true when the platform resolved an account whose handle equals the
            requested term. false when `profiles` is a best-effort candidate list.
        profiles:
          type: array
          description: The matching account when exact_match is true, otherwise candidate matches.
          items:
            $ref: "#/components/schemas/DataProfile"

    ProfilePlatformStatus:
      type: object
      description: Per-(platform, term) lookup status for the profile endpoint.
      required: [platform, term, success]
      properties:
        platform:
          type: string
        term:
          type: string
        success:
          type: boolean
        count:
          type: integer
          description: Number of profiles returned for this pair (present on success).
        error_code:
          type: string
          enum:
            - unknown_platform
            - fetch_timeout
            - invalid_term
            - platform_error
        message:
          type: string
          description: |
            Human-readable reason. `invalid_term` carries the platform's own
            explanation of what was wrong with the term; every other code returns
            a generic message, never internal error details.

    DataProfile:
      type: object
      description: Normalized profile returned by the Data API.
      required: [platform, metrics]
      properties:
        platform:
          type: string
        platform_id:
          type: string
          nullable: true
        handle:
          type: string
          nullable: true
        display_name:
          type: string
          nullable: true
        url:
          type: string
          nullable: true
        image:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        verified:
          type: boolean
          nullable: true
        metrics:
          $ref: "#/components/schemas/DataProfileMetrics"

    DataProfileMetrics:
      type: object
      description: Profile metrics. Any value not available for an account is null.
      properties:
        follower_count:
          type: integer
          nullable: true
        post_count:
          type: integer
          nullable: true
        total_view_count:
          type: integer
          nullable: true
          description: Lifetime view count across all of the account's posts.
        avg_views_per_post:
          type: integer
          nullable: true
          description: Mean views per post (total_view_count / post_count).
        language:
          type: string
          nullable: true
          description: Locale string for the account's content.
        location:
          type: string
          nullable: true
          description: Location associated with the account (city, country, region) — free-text, not an ISO country code.
        recent_post_engagement:
          type: object
          nullable: true
          description: Engagement counts from one recent post, for computing an engagement rate.
          properties:
            likes:
              type: integer
              nullable: true
            comments:
              type: integer
              nullable: true
            shares:
              type: integer
              nullable: true
            views:
              type: integer
              nullable: true
            sampled_post_url:
              type: string
              nullable: true
            sampled_post_created_at:
              type: string
              format: date-time
              nullable: true
              description: ISO-8601 timestamp of the sampled post. Null when no post timestamp is available.

    Post:
      type: object
      description: Post returned by the Data API
      properties:
        platform:
          type: string
        platform_id:
          type: string
          nullable: true
        url:
          type: string
          nullable: true
        post_created_at:
          type: string
          format: date-time
          nullable: true
        message:
          type: string
          nullable: true
        like_count:
          type: integer
          description: >
            Like/reaction count. Present for every platform that has a concept
            of engagement. Omitted entirely for platforms that report none
            (RSS/blog feeds, Google reviews).
        comment_count:
          type: integer
          description: >
            Comment/reply count. Present for every platform that has a concept
            of engagement. Omitted entirely for platforms that report none
            (RSS/blog feeds, Google reviews).
        share_count:
          type: integer
          description: >
            Public share/repost count. Present only for platforms that collect
            it (Facebook, TikTok, X, Bluesky). Omitted entirely for other
            platforms.
        view_count:
          type: integer
          description: >
            Video view count. Present only for YouTube and TikTok. Omitted for
            platforms that don't collect it.
        quote_count:
          type: integer
          description: >
            Number of times the post has been quoted. Present only for X and
            Bluesky. Omitted for platforms that don't collect it.
        bookmark_count:
          type: integer
          description: >
            Bookmark count. Present only for X. Omitted for platforms that
            don't collect it.
        impression_count:
          type: integer
          description: >
            Impression count. Present only for X. Omitted for platforms that
            don't collect it.
        tagged_users:
          type: string
        poster:
          $ref: "#/components/schemas/Poster"
        media:
          type: array
          items:
            $ref: "#/components/schemas/Medium"
        referenced_post:
          nullable: true
          $ref: "#/components/schemas/ReferencedPost"
        reshared_by:
          nullable: true
          $ref: "#/components/schemas/Poster"

    Poster:
      type: object
      properties:
        display_name:
          type: string
        name:
          type: string
        url:
          type: string
          nullable: true
        image:
          type: string
          nullable: true
        platform_id:
          type: string
          nullable: true

    Medium:
      type: object
      properties:
        type:
          type: string
          description: >-
            `link` is a shared article rather than a picture: `url` is the destination
            article and `preview_image_url` is its preview image (null when the source
            offered none, or only one too small to show).
          enum: [image, video, link]
        url:
          type: string
        preview_image_url:
          type: string
          nullable: true
        width:
          type: integer
          nullable: true
        height:
          type: integer
          nullable: true
        alt_text:
          type: string
          nullable: true
        platform_id:
          type: string
          nullable: true

    ReferencedPost:
      type: object
      properties:
        poster:
          $ref: "#/components/schemas/Poster"
        message:
          type: string
          nullable: true
        url:
          type: string
          nullable: true
        platform_id:
          type: string
          nullable: true
        post_created_at:
          type: string
          format: date-time
          nullable: true
        media:
          type: array
          items:
            $ref: "#/components/schemas/Medium"

    PlatformStatus:
      type: object
      required: [platform, success]
      properties:
        platform:
          type: string
        success:
          type: boolean
        count:
          type: integer
          description: Number of posts returned for this platform on this page (present on success).
        next_cursor:
          type: string
          nullable: true
          description: |
            Opaque cursor for the next page of this platform's results. Pass it back
            in the `cursor` query param to continue. `null` when there are no more
            results (or the platform does not support pagination, e.g. Pinterest).
        has_more:
          type: boolean
          description: True when `next_cursor` is present (more results are available).
        error_code:
          type: string
          enum:
            - unknown_platform
            - invalid_term_type
            - fetch_timeout
            - invalid_term
            - platform_error
            - social_account_required
            - invalid_cursor
        message:
          type: string
          description: |
            Human-readable reason. `invalid_term` carries the platform's own
            explanation of what was wrong with the term; every other code returns
            a generic message, never internal error details.

    SignInToken:
      type: object
      required: [sign_in_token, sign_in_url, expires_at]
      properties:
        sign_in_token:
          type: string
          description: Short-lived, single-use token that signs the user in.
        sign_in_url:
          type: string
          description: |
            Ready-to-use URL that signs the user in and redirects to their
            dashboard. Built against the account's white-label host when one is
            configured. Use it as the `src` of an embedding iframe.
        expires_at:
          type: string
          format: date-time
          description: The token expires after 30 minutes.

    User:
      type: object
      required: [id, email, role, invitation_accepted, invitation_pending, feed_ids, created_at]
      properties:
        id:
          type: integer
        email:
          type: string
          format: email
        role:
          type: string
          enum: [owner, manager, collaborator]
        invitation_accepted:
          type: boolean
          description: Whether the user has accepted a Devise invitation (true for any user who signed up normally).
        invitation_pending:
          type: boolean
          description: True when the user has an outstanding unaccepted invitation.
        feed_ids:
          type: array
          description: |
            IDs of feeds the user currently has an assignment for. For managers this
            will reflect the assignments created when they accepted their invitation;
            for collaborators it reflects the feeds they were assigned to.
          items:
            type: integer
        created_at:
          type: string
          format: date-time

    UserCreateRequest:
      type: object
      required: [email, role]
      properties:
        email:
          type: string
          format: email
        role:
          type: string
          enum: [manager, collaborator]
        feed_ids:
          type: array
          description: |
            Optional. When `role` is `collaborator` and feeds are provided, the
            collaborator is assigned to each feed on creation (all must belong
            to the current account). When omitted or empty, the collaborator
            is created without any assignments and feeds can be attached later.
            Ignored when `role` is `manager`.
          items:
            type: integer
        skip_invitation_email:
          type: boolean
          default: false
          description: |
            When `true`, Juicer does not email the invited user. This suppresses
            both the Devise invitation email and, for collaborators with
            `feed_ids`, the "new feed" notification. Use this when your
            application handles user onboarding/notifications itself and the
            end-user should not be aware of Juicer as a separate product.

    # ── Error schemas ────────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: |
                Machine-readable error code. Possible values:
                - `unauthorized` — Missing or invalid API key
                - `forbidden` — Caller is not permitted (see `message`)
                - `not_found` — Resource not found
                - `parameter_missing` — Required parameter not provided
                - `validation_failed` — Model validation failed (see `details`)
                - `feed_limit_reached` — Plan's feed limit exceeded (see `action`)
                - `source_limit_reached` — Feed's source limit exceeded (see `action`)
                - `user_limit_reached` — Plan doesn't include team members (see `action`)
                - `social_account_required` — OAuth connection needed (see `action`)
                - `invalid_platform` — Platform not supported
                - `invalid_provider` — Unknown OAuth provider
                - `invalid_status` — Invalid post status filter
                - `invalid_action_type` — Invalid bulk moderation action
                - `post_unavailable` — Post no longer available on the source platform
                - `query_required` — Search query not provided
                - `search_too_broad` — Search timed out; narrow it with `feed_ids`, a shorter date range or a more specific query
                - `user_has_connections_in_use` — Removing the user would stop feeds that sync on their connections (see `details`)
              example: "feed_limit_reached"
            message:
              type: string
              description: Human-readable error message
              example: "Your Free plan allows 1 feed(s). Upgrade to create more."
            details:
              type: object
              nullable: true
              description: |
                Field-level validation errors when `code` is `validation_failed`.
                When `code` is `user_has_connections_in_use`, `social_accounts` lists the
                connections (`id`, `provider`, `name`, `feed_names`) that feeds still sync on.
              additionalProperties:
                type: array
              example: { "name": ["can't be blank"] }
            action:
              nullable: true
              description: |
                Actionable fix for the error. Present when the error can be resolved
                by the user taking a specific action (upgrading plan or connecting a social account).
                The `type` field tells you what kind of action is needed.
              oneOf:
                - $ref: "#/components/schemas/UpgradePlanAction"
                - $ref: "#/components/schemas/ConnectSocialAccountAction"

    UpgradePlanAction:
      type: object
      description: |
        Returned when a plan limit is hit (feed limit, source limit).
        The `upgrade_url` is a magic link — the user clicks it, gets signed in
        automatically, and lands on the upgrade page with the right plan pre-selected.
      properties:
        type:
          type: string
          enum: [upgrade_plan]
        current_plan:
          type: string
          example: "small"
        current_plan_display_name:
          type: string
          example: "Free"
        required_plan:
          type: string
          description: The minimum plan that unlocks this feature
          example: "large"
        required_plan_display_name:
          type: string
          example: "Pro"
        upgrade_url:
          type: string
          description: Magic link — signs user in and redirects to the upgrade page. Expires in 30 minutes.
        expires_at:
          type: string
          format: date-time

    ConnectSocialAccountAction:
      type: object
      description: |
        Returned when a source requires an OAuth-connected social account.
        The `connection_url` is a magic link — the user clicks it, gets signed in
        automatically, and starts the OAuth authorization flow.
      properties:
        type:
          type: string
          enum: [connect_social_account]
        provider:
          type: string
          description: OAuth provider to connect
          example: "facebook"
        connection_url:
          type: string
          description: Magic link — signs user in and starts OAuth flow. Expires in 30 minutes.
        expires_at:
          type: string
          format: date-time
