openapi: 3.1.0
info:
  title: Klyky API
  version: 1.0.0
  description: |
    Public API for Klyky — create, schedule and publish social media content, and
    read back real metrics.

    ## Authentication
    Bearer token: `Authorization: Bearer kv_live_<prefix>_<secret>`.
    Keys are created in Settings → API and shown exactly once; only a SHA-256
    hash is stored, so a lost key must be replaced rather than recovered.

    ## Scopes
    Every key carries an explicit scope list. A request outside a key's scopes
    returns 403 `insufficient_scope`.

    ## Idempotency
    `POST /v1/posts` requires an `Idempotency-Key` header. Replaying the same key
    with the same body returns the original response instead of creating a second
    post. Reusing a key with a *different* body returns 409 — safer than silently
    returning the wrong resource.

    ## Rate limits
    Per key, per minute, configurable when the key is created (default 60).
    Exceeding it returns 429 `rate_limited`.

    ## Metrics and nulls
    Any metric a platform does not report is `null`, never `0`. Treat the two as
    different: `null` is "not measured", `0` is "measured as zero". Klyky never
    estimates or interpolates a metric.

    ## Webhooks
    Settings → Webhooks registers a URL and the events it receives. Each
    delivery is a `POST` with `{ "event", "data", "sentAt" }`, signed with
    `X-Klyky-Signature: sha256=<hmac>` over `<X-Klyky-Timestamp>.<raw body>` with
    the endpoint's secret. Reject deliveries older than a few minutes. Failed
    deliveries are retried with backoff.

    | Event | Sent when | `data` |
    |---|---|---|
    | `post.scheduled` | a post is scheduled or rescheduled | `postId`, `scheduledAt` |
    | `post.published` | at least one network published it | `postId`, `publishedTargets`, `failedTargets` |
    | `post.failed` | one or more networks failed, or publishing gave up | `postId`, `failedTargets` or `error` |
    | `approval.requested` | a post was sent for approval | `postId`, `note`, `via` |
    | `approval.decided` | an approver approved or asked for changes | `postId`, `decision`, `note` |
    | `comment.created` | someone (or an agent) commented on a post | `postId`, `commentId`, `body`, `via` |
    | `account.disconnected` | a connected account expired or was revoked | `socialAccountId`, `platform`, `status` |

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

security:
  - bearerAuth: []

tags:
  - name: Posts
  - name: Accounts
  - name: Analytics
  - name: AI

paths:
  /posts:
    get:
      tags: [Posts]
      summary: List posts
      description: Scoped to the workspace the API key belongs to.
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/PostStatus' } }
        - { name: brand_id, in: query, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: A page of posts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Post' } }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

    post:
      tags: [Posts]
      summary: Create a post
      description: |
        With `status: "scheduled"` the post is queued for publishing — immediately
        when `scheduled_at` is omitted, otherwise at that time. With `"draft"` it
        is stored and appears on the Kanban board for a human to approve.

        Requires the `posts:write` scope and an `Idempotency-Key`.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: A unique value per logical create, e.g. a UUID.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePostRequest' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties: { data: { $ref: '#/components/schemas/Post' } }
        '200':
          description: Replayed — this Idempotency-Key was already used with the same body.
          content:
            application/json:
              schema:
                type: object
                properties: { data: { $ref: '#/components/schemas/Post' } }
        '404':
          description: The brand or one of the social accounts does not exist in this workspace.
        '409':
          description: The Idempotency-Key was reused with a different body, or is still in flight.
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /posts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Posts]
      summary: Get a post, including per-account delivery state
      responses:
        '200':
          description: The post.
          content:
            application/json:
              schema:
                type: object
                properties: { data: { $ref: '#/components/schemas/PostDetail' } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Posts]
      summary: Delete a post
      description: |
        Refused for a published post: it records something that happened on a
        network Klyky does not control, and deleting it here would not unpublish it.
      responses:
        '204': { description: Deleted. }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: The post is already published. }

  /accounts:
    get:
      tags: [Accounts]
      summary: List connected social accounts
      description: |
        `supports_metrics` is false when the account was connected without the
        metrics permission. Such an account publishes normally but will never
        return analytics, and the only fix is reconnecting it.
        `publishing_enabled` is false when posting was switched off locally
        without disconnecting OAuth.
      responses:
        '200':
          description: Connected accounts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/SocialAccount' } }

  /analytics:
    get:
      tags: [Analytics]
      summary: Read captured metrics
      description: |
        One row per post target per capture day. Values are cumulative: the
        latest `captured_on` of a `post_target_id` is its current total, so do
        not sum days. Unreported metrics are null. Durations are milliseconds.
      parameters:
        - { name: post_id, in: query, schema: { type: string, format: uuid } }
        - { name: social_account_id, in: query, schema: { type: string, format: uuid } }
        - { name: days, in: query, schema: { type: integer, default: 30, maximum: 365 } }
      responses:
        '200':
          description: Metric captures.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/MetricCapture' } }

  /generate:
    post:
      tags: [AI]
      summary: Generate copy with the workspace's brand voice
      description: |
        Runs through the same AI gateway as the in-app copilot, so output follows
        the brand's voice profile and spends the workspace's daily quota.

        The AI is chosen by Klyky for each task; responses never name a model or
        provider, and a `model` field in the request is ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/GenerateCaption'
                - $ref: '#/components/schemas/GenerateRewrite'
                - $ref: '#/components/schemas/GenerateHashtags'
                - $ref: '#/components/schemas/GenerateCarousel'
              discriminator: { propertyName: kind }
      responses:
        '200':
          description: Generated content.
        '201':
          description: '`kind: carousel` with `materialize: true` — the draft was created.'
        '400':
          description: The workspace's AI is not configured (e.g. own keys without a key).
        '429':
          description: The workspace's daily AI request cap has been reached.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer

  responses:
    Unauthorized:
      description: Missing, invalid or revoked API key.
    Forbidden:
      description: The key does not carry the required scope.
    NotFound:
      description: Not found in this workspace.
    RateLimited:
      description: Per-key rate limit exceeded.
    ValidationError:
      description: The request body failed validation.

  schemas:
    PostStatus:
      type: string
      enum: [idea, draft, in_review, approved, scheduled, published, failed]

    Platform:
      type: string
      enum: [facebook, instagram, x, tiktok, tiktok_business, youtube, pinterest, linkedin, bluesky, threads]

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

    PostTarget:
      type: object
      properties:
        id: { type: string, format: uuid }
        platform: { $ref: '#/components/schemas/Platform' }
        status:
          type: string
          enum: [pending, queued, processing, published, failed, cancelled]
        platform_url:
          type: [string, 'null']
          description: The live post on the network, once published.
        platform_post_id: { type: [string, 'null'] }
        error: { type: [object, 'null'] }

    Post:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: [string, 'null'] }
        caption: { type: string }
        status: { $ref: '#/components/schemas/PostStatus' }
        brand_id: { type: string, format: uuid }
        scheduled_at: { type: [string, 'null'], format: date-time }
        published_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        targets: { type: array, items: { $ref: '#/components/schemas/PostTarget' } }

    PostDetail:
      allOf:
        - $ref: '#/components/schemas/Post'
        - type: object
          properties:
            error:
              type: [object, 'null']
              description: Set when publishing failed.

    CreatePostRequest:
      type: object
      required: [brand_id]
      properties:
        brand_id: { type: string, format: uuid }
        caption: { type: string, maxLength: 20000, default: '' }
        title: { type: string, maxLength: 200 }
        status:
          type: string
          enum: [idea, draft, scheduled]
          default: draft
        scheduled_at:
          type: [string, 'null']
          format: date-time
          description: With status "scheduled", omitting this publishes immediately.
        timezone: { type: string, default: Europe/Lisbon }
        targets:
          type: array
          items:
            type: object
            required: [social_account_id, platform]
            properties:
              social_account_id: { type: string, format: uuid }
              platform: { $ref: '#/components/schemas/Platform' }
              caption:
                type: [string, 'null']
                description: Overrides the post caption for this network only.
              options:
                type: object
                description: >
                  Per-network settings, e.g. placement (feed/reels/stories), a
                  YouTube title and privacy, Pinterest board ids, or thread items
                  for X, Threads and Bluesky.
        asset_ids:
          type: array
          maxItems: 35
          items: { type: string, format: uuid }
          description: >
            Media library assets to attach, in order (the carousel order).
            Requires the `media:read` scope.

    SocialAccount:
      type: object
      properties:
        id: { type: string, format: uuid }
        platform: { $ref: '#/components/schemas/Platform' }
        username: { type: [string, 'null'] }
        display_name: { type: [string, 'null'] }
        status:
          type: string
          enum: [connected, disconnected, expired]
        brand_id: { type: [string, 'null'], format: uuid }
        supports_metrics:
          type: boolean
          description: False when connected without the metrics permission.
        publishing_enabled:
          type: boolean
          description: False when posting is switched off without disconnecting.

    MetricCapture:
      type: object
      description: Every metric is nullable; null means the platform did not report it.
      properties:
        post_id: { type: string, format: uuid }
        post_target_id: { type: string, format: uuid }
        social_account_id: { type: string, format: uuid }
        platform: { $ref: '#/components/schemas/Platform' }
        platform_post_id: { type: [string, 'null'] }
        captured_on: { type: string, format: date }
        media_type:
          type: [string, 'null']
          enum: [video, image, carousel, text, null]
        thumbnail_url: { type: [string, 'null'] }
        impressions: { type: [integer, 'null'] }
        reach: { type: [integer, 'null'] }
        likes: { type: [integer, 'null'] }
        comments: { type: [integer, 'null'] }
        shares: { type: [integer, 'null'] }
        saves: { type: [integer, 'null'] }
        views: { type: [integer, 'null'] }
        video_views: { type: [integer, 'null'] }
        clicks: { type: [integer, 'null'] }
        engagement_rate:
          type: [number, 'null']
          description: Percentage, on every platform.
        quotes: { type: [integer, 'null'], description: Threads quotes. }
        dislikes: { type: [integer, 'null'], description: YouTube dislikes. }
        interactions: { type: [integer, 'null'] }
        watch_time_ms: { type: [integer, 'null'] }
        avg_watch_time_ms: { type: [integer, 'null'] }
        video_length_ms: { type: [integer, 'null'] }
        completed_views: { type: [integer, 'null'] }
        views_15s: { type: [integer, 'null'] }
        unique_viewers: { type: [integer, 'null'] }
        engaged_views: { type: [integer, 'null'] }
        avg_view_percentage: { type: [number, 'null'] }
        subscribers_gained: { type: [integer, 'null'] }

    GenerateCaption:
      type: object
      required: [kind, topic]
      properties:
        kind: { type: string, const: caption }
        topic: { type: string, minLength: 3, maxLength: 3000 }
        brand_id: { type: [string, 'null'], format: uuid }
        platform: { $ref: '#/components/schemas/Platform' }

    GenerateRewrite:
      type: object
      required: [kind, text]
      properties:
        kind: { type: string, const: rewrite }
        text: { type: string, minLength: 1, maxLength: 5000 }
        mode:
          type: string
          enum: [rewrite, shorten, lengthen, formal, casual, adapt]
          default: rewrite
          description: '`adapt` reworks the text for the rules of the network in `platform` (required in that mode).'
        brand_id: { type: [string, 'null'], format: uuid }
        platform: { $ref: '#/components/schemas/Platform' }

    GenerateHashtags:
      type: object
      required: [kind, text]
      properties:
        kind: { type: string, const: hashtags }
        text: { type: string, minLength: 1, maxLength: 5000 }
        brand_id: { type: [string, 'null'], format: uuid }

    GenerateCarousel:
      type: object
      required: [kind, topic]
      properties:
        kind: { type: string, const: carousel }
        topic: { type: string, minLength: 3, maxLength: 3000 }
        slide_count: { type: integer, minimum: 2, maximum: 10, default: 5 }
        brand_id:
          type: [string, 'null']
          format: uuid
          description: Required with `materialize`.
        materialize:
          type: boolean
          default: false
          description: |
            Also generate one 4:5 image per slide (the slide's text is rendered on
            top of the art), store them in the media library and create a
            **draft** post with the caption and the images in slide order. The
            first slide is the hook and the last the call to action. Never
            publishes or schedules: a person approves it on the board. Requires
            `brand_id` and the `posts:write` and `media:write` scopes, and spends
            the carousel text plus one image per slide. Responds 201 with
            `post_id`, `caption`, `complete` and `slides[]` (`position`, `role`,
            `heading`, `body`, `status`, `asset_id`, `error`). A slide that failed
            is reported with `status: failed`; the draft cannot be scheduled
            until it is retried in the editor.
        account_ids:
          type: array
          items: { type: string, format: uuid }
          description: Social accounts to target in the draft (e.g. the brand's Instagram).
