openapi: 3.1.0
info:
  title: LinkedIn Carousel Maker API
  version: "1.0.0"
  description: |
    Public API for creating, generating, editing and exporting LinkedIn carousels.
    Create a key in Settings → API keys and send it as `Authorization: Bearer lcm_<key>`.
    Every request counts toward a limit of 100 requests per day per account (UTC day);
    over the limit returns 429 with a `Retry-After` header (seconds until UTC midnight).
    AI routes also spend the monthly AI meters (5 generations, 30 edits on free accounts).
servers:
  - url: /api/v1
security:
  - bearerAuth: []

paths:
  /me:
    get:
      summary: The key's user, plan and monthly AI usage
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [user, plan, usage]
                properties:
                  user:
                    type: object
                    required: [id, email, name]
                    properties: { id: { type: string }, email: { type: string }, name: { type: string } }
                  plan: { type: string, enum: [free, pro] }
                  usage: { $ref: "#/components/schemas/Usage" }
        default: { $ref: "#/components/responses/Error" }

  /layouts:
    get:
      summary: Slide layouts and their fields
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [layouts]
                properties:
                  layouts:
                    type: array
                    items:
                      type: object
                      required: [id, kind, allowedKinds, name, description, fields]
                      properties:
                        id: { type: string }
                        kind: { $ref: "#/components/schemas/SlideKind" }
                        allowedKinds: { type: array, items: { $ref: "#/components/schemas/SlideKind" }, description: "Kinds a slide on this layout may have" }
                        name: { type: string }
                        description: { type: string }
                        fields:
                          type: array
                          items:
                            type: object
                            required: [key, type, label, required]
                            properties:
                              key: { type: string }
                              type: { type: string, enum: [text, list, image, number, icon, elements] }
                              label: { type: string }
                              required: { type: boolean }
                              max: { type: integer, description: "text: max words; list: max items" }
        default: { $ref: "#/components/responses/Error" }

  /templates:
    get:
      summary: Starter templates
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items:
                      type: object
                      required: [id, name, description, category, slideCount]
                      properties:
                        id: { type: string }
                        name: { type: string }
                        description: { type: string }
                        category: { type: string, enum: [story, list, framework, data, hiring, launch] }
                        slideCount: { type: integer }
        default: { $ref: "#/components/responses/Error" }

  /carousels:
    get:
      summary: List your saved carousels (newest first)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [carousels]
                properties:
                  carousels:
                    type: array
                    items:
                      type: object
                      required: [id, title, data, updatedAt]
                      properties:
                        id: { type: string }
                        title: { type: string }
                        thumbnailUrl: { type: [string, "null"] }
                        data: { $ref: "#/components/schemas/Carousel" }
                        updatedAt: { type: string, format: date-time }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Save a new carousel
      description: Free accounts can save up to 10 carousels (402 `limit_reached`). An existing id is 409 `conflict`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Carousel" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Saved" }
        default: { $ref: "#/components/responses/Error" }

  /carousels/generate:
    post:
      summary: Generate a carousel with AI and save it
      description: |
        Spends one `ai_generate` credit (refunded if generation fails). The carousel is saved;
        if the 10-carousel cap is reached it is still returned with `saved: false`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source]
              properties:
                source:
                  type: object
                  required: [type, value]
                  properties:
                    type: { type: string, enum: [topic, text, url] }
                    value: { type: string, minLength: 1, maxLength: 50000 }
                slides: { type: integer, minimum: 5, maximum: 12 }
                tone: { type: string, enum: [professional, conversational, bold, storytelling, analytical] }
                language: { type: string, maxLength: 40 }
                preset: { $ref: "#/components/schemas/Preset" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [carousel, saved, truncated, usage]
                properties:
                  carousel: { $ref: "#/components/schemas/Carousel" }
                  saved: { type: boolean }
                  truncated: { type: boolean, description: "The source was over 12,000 characters; only the first part was used." }
                  usage: { $ref: "#/components/schemas/Usage" }
        default: { $ref: "#/components/responses/Error" }

  /carousels/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      summary: Get a saved carousel
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [id, data, updatedAt]
                properties:
                  id: { type: string }
                  data: { $ref: "#/components/schemas/Carousel" }
                  updatedAt: { type: string, format: date-time }
        default: { $ref: "#/components/responses/Error" }
    put:
      summary: Replace a saved carousel
      description: |
        Body is the full Carousel (its `id` must equal the path id). Pass `expectedUpdatedAt`
        (from a previous GET/PUT) to get 409 `conflict` instead of overwriting a newer change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/Carousel" }
                - type: object
                  properties:
                    expectedUpdatedAt: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [updatedAt]
                properties: { updatedAt: { type: string, format: date-time } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Delete a saved carousel
      responses:
        "204": { description: Deleted }
        default: { $ref: "#/components/responses/Error" }

  /carousels/{id}/edit:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      summary: Edit a saved carousel (or one slide) with AI and save the result
      description: |
        Spends one `ai_edit` credit (refunded if the edit or the save fails). `slideId` is
        required when `mode` is `slide`. 409 `conflict` if the carousel changed while editing.

        Edits are text-only merges: styles, icons, field visibility and Canvas geometry are kept.
        On a `canvas` slide the AI may only rewrite the text of existing, visible text elements by id
        (the `CanvasEdit` shape); it cannot move, restyle, add or remove elements. In a carousel edit,
        canvas slides keep their id and layout, may be reordered, and may be dropped only when the
        slide count changes. A canvas edit that breaks these rules fails with 400 `invalid_body`
        "Canvas slides accept text edits only" and the credit is refunded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt, mode]
              properties:
                prompt: { type: string, minLength: 1, maxLength: 500 }
                mode: { type: string, enum: [slide, carousel] }
                slideId: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [carousel, usage]
                properties:
                  carousel: { $ref: "#/components/schemas/Carousel" }
                  usage: { $ref: "#/components/schemas/Usage" }
        default: { $ref: "#/components/responses/Error" }

  /carousels/{id}/export:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      summary: Render a saved carousel to PDF or a ZIP of PNGs
      description: |
        Server-rendered with headless Chromium. Free plan: `pdf` only, up to 10 slides, rendered at 1x. Pro: `pdf` or `png`,
        up to 20 slides, 2x. 403 `pro_required` otherwise. 501 `export_unavailable` when the deployment has export disabled.
        The browser export in the editor has no such limits.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format]
              properties:
                format: { type: string, enum: [pdf, png], description: "png returns a ZIP with one PNG per slide" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [url, expiresAt]
                properties:
                  url: { type: string, format: uri }
                  expiresAt: { type: string, format: date-time, description: "advisory; files may be removed after this time" }
        default: { $ref: "#/components/responses/Error" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key: lcm_ followed by 32 URL-safe characters."

  responses:
    Error:
      description: |
        Error. Codes: 400 invalid_body / invalid_source, 401 invalid_key, 402 limit_reached, 403 pro_required / email_unverified, 404 not_found,
        409 conflict, 413 too_large, 422 ai_invalid_output / source_unreadable, 429 rate_limited,
        501 export_unavailable, 502 ai_unavailable, 500 internal_error / ai_unconfigured.
      headers:
        Retry-After:
          description: On 429, seconds until the daily limit resets (UTC midnight).
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

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

    Saved:
      type: object
      required: [id, updatedAt]
      properties:
        id: { type: string }
        updatedAt: { type: string, format: date-time }

    Usage:
      type: object
      description: Monthly AI meters.
      required: [ai_generate, ai_edit]
      properties:
        ai_generate: { $ref: "#/components/schemas/Meter" }
        ai_edit: { $ref: "#/components/schemas/Meter" }
    Meter:
      type: object
      required: [used, limit]
      properties: { used: { type: integer }, limit: { type: integer } }

    Preset:
      type: string
      enum: [linkedin-4x5, linkedin-1x1]
      description: "linkedin-4x5 = 1080×1350 px, linkedin-1x1 = 1080×1080 px"
    SlideKind:
      type: string
      enum: [intro, content, outro]
    Color:
      type: string
      pattern: "^#[0-9a-fA-F]{6}$"

    TextStyle:
      type: object
      description: Optional styling for a whole text or list field. Every key is optional.
      properties:
        size: { type: string, enum: [xs, s, m, l, xl] }
        weight: { type: string, enum: [regular, medium, semibold, bold] }
        italic: { type: boolean }
        align: { type: string, enum: [left, center, right] }
        color: { type: string, description: "text, accent, muted, or #rrggbb" }
        font: { type: string, description: "title, body, or a Google Fonts family" }
        lineHeight: { type: string, enum: [tight, normal, loose] }
        tracking: { type: string, enum: [tight, normal, wide] }

    Field:
      oneOf:
        - type: object
          required: [type, on, value]
          properties:
            type: { const: text }
            on: { type: boolean }
            value: { type: string }
            style: { $ref: "#/components/schemas/TextStyle" }
        - type: object
          required: [type, on, value]
          properties:
            type: { const: list }
            on: { type: boolean }
            value: { type: array, items: { type: string } }
            style: { $ref: "#/components/schemas/TextStyle" }
        - type: object
          required: [type, on, value, fit]
          properties:
            type: { const: image }
            on: { type: boolean }
            value: { type: string, description: Image URL }
            fit: { type: string, enum: [cover, contain] }
            credit: { type: string }
        - type: object
          required: [type, on, value]
          properties: { type: { const: number }, on: { type: boolean }, value: { type: number } }
        - type: object
          required: [type, on, value]
          properties:
            type: { const: icon }
            on: { type: boolean }
            value: { type: string, enum: [trending-up, trending-down, bar-chart-3, line-chart, piggy-bank, coins, wallet, target, rocket, award, users, user-round, handshake, heart, message-circle, thumbs-up, smile, megaphone, mail, globe, check, check-circle, x, list-checks, repeat, clock, calendar, settings, layers, workflow, lightbulb, sparkles, zap, brain, book-open, puzzle, compass, search, pencil, graduation-cap, quote, star, flag, alert-triangle, shield-check, lock, eye, bookmark, bell, flame, arrow-right, arrow-up-right, move-right, corner-down-right, arrow-down, chevron-right] }
            size: { type: integer, minimum: 48, maximum: 400 }
            color: { type: string, description: "text, accent, muted, or #rrggbb" }
        - type: object
          description: |
            The canvas layout's elements. At most 24, ids unique. Geometry is in slide pixels from the slide's top-left
            (linkedin-4x5 is 1080×1350, linkedin-1x1 is 1080×1080). The editor keeps elements inside the content bounds,
            72 px from each edge with a 120 px chrome band at the bottom.
          required: [type, on, value]
          properties:
            type: { const: elements }
            on: { type: boolean }
            value:
              type: array
              maxItems: 24
              items:
                oneOf:
                  - type: object
                    required: [kind, id, x, y, w, role, text]
                    properties:
                      kind: { const: text }
                      id: { type: string, minLength: 1 }
                      x: { type: number }
                      y: { type: number }
                      w: { type: number, exclusiveMinimum: 0 }
                      visible: { type: boolean, description: Absent means visible. }
                      role: { type: string, enum: [title, body] }
                      text: { type: string, maxLength: 400 }
                      style: { $ref: "#/components/schemas/TextStyle" }
                  - type: object
                    required: [kind, id, x, y, size, name]
                    properties:
                      kind: { const: icon }
                      id: { type: string, minLength: 1 }
                      x: { type: number }
                      y: { type: number }
                      size: { type: number, exclusiveMinimum: 0 }
                      visible: { type: boolean, description: Absent means visible. }
                      name: { type: string, enum: [trending-up, trending-down, bar-chart-3, line-chart, piggy-bank, coins, wallet, target, rocket, award, users, user-round, handshake, heart, message-circle, thumbs-up, smile, megaphone, mail, globe, check, check-circle, x, list-checks, repeat, clock, calendar, settings, layers, workflow, lightbulb, sparkles, zap, brain, book-open, puzzle, compass, search, pencil, graduation-cap, quote, star, flag, alert-triangle, shield-check, lock, eye, bookmark, bell, flame, arrow-right, arrow-up-right, move-right, corner-down-right, arrow-down, chevron-right] }
                      color: { type: string, description: "text, accent, muted, or #rrggbb" }
                discriminator: { propertyName: kind }
      discriminator: { propertyName: type }

    CanvasEdit:
      type: object
      description: |
        The only change an AI edit may make to a canvas slide: new text for existing, visible text elements, by id.
        Omitted elements stay as they are. Unknown, hidden or repeated ids are rejected (400 `invalid_body`,
        "Canvas slides accept text edits only"). In a carousel edit a canvas slide is returned as `{ id, layout: "canvas", elements }`.
      required: [elements]
      properties:
        elements:
          type: array
          minItems: 1
          items:
            type: object
            required: [id, text]
            properties:
              id: { type: string, minLength: 1 }
              text: { type: string, maxLength: 400 }

    Slide:
      type: object
      required: [id, kind, layout, fields]
      properties:
        id: { type: string }
        kind: { $ref: "#/components/schemas/SlideKind" }
        layout: { type: string, description: "A layout id from GET /layouts. Each layout has one kind, except canvas, which may be intro, content or outro." }
        fields:
          type: object
          description: Keyed by the layout's field keys.
          additionalProperties: { $ref: "#/components/schemas/Field" }

    Carousel:
      type: object
      required: [version, settings, slides]
      properties:
        id: { type: string, description: Generated when omitted on create. }
        version: { const: 1 }
        title: { type: string, maxLength: 120, default: Untitled carousel }
        caption: { type: string, maxLength: 3000, default: "" }
        settings:
          type: object
          required: [preset, theme, fonts, author, counter, swipe, pattern]
          properties:
            preset: { $ref: "#/components/schemas/Preset" }
            theme:
              type: object
              required: [background, text, accent, muted]
              properties:
                background: { $ref: "#/components/schemas/Color" }
                text: { $ref: "#/components/schemas/Color" }
                accent: { $ref: "#/components/schemas/Color" }
                muted: { $ref: "#/components/schemas/Color" }
            fonts:
              type: object
              required: [title, body]
              properties: { title: { type: string, description: Google Fonts family }, body: { type: string } }
            author:
              type: object
              required: [name, handle, title, avatarUrl, show]
              properties:
                name: { type: string }
                handle: { type: string }
                title: { type: string }
                avatarUrl: { type: string }
                show: { type: string, enum: [all, intro-outro, none] }
            counter:
              type: object
              required: [show, style, includeIntro]
              properties:
                show: { type: boolean }
                style: { type: string, enum: [chip, plain] }
                includeIntro: { type: boolean }
            swipe:
              type: object
              required: [show, text]
              properties: { show: { type: boolean }, text: { type: string } }
            pattern:
              type: object
              required: [id, opacity]
              properties:
                id: { type: string, enum: [none, dots, grid, lines, noise] }
                opacity: { type: number, minimum: 0, maximum: 1 }
        slides:
          type: array
          minItems: 1
          maxItems: 20
          items: { $ref: "#/components/schemas/Slide" }
