openapi: 3.0.3
info:
  title: Promoat Partner API
  version: "1.0.0"
  description: |
    API for Promoat B2B partners: mint an API key, buy credits, then generate AI presenters
    (characters), clone voices, synthesize speech, generate first-frame images, and produce
    videos — single-scene talking-head, or multi-scene where your character acts the script out.

    **Auth**: every `/api/partner/v1/*` endpoint takes `Authorization: Bearer pmat_<key>`.
    Click **Authorize** above and paste your key once — it's attached to every request you
    try below.

    **Credits**: everything is priced in prepaid credits. Async endpoints (`/videos`) reserve an
    estimate at queue time and charge the measured actual cost on completion (refunding any
    over-reserve); sync endpoints (`/characters`, `/voices`, `/tts`, `/images/first-frame`) charge
    once, immediately.
    `credits_consumed` on a job is `null` while `queued`/`running`, `actual_credits` (falling
    back to `estimated_credits`) once `completed`, and `0` on `failed` unless a partial
    `actual_credits` was recorded.
  contact:
    name: Promoat Partner API
servers:
  - url: /
    description: This deployment (matches whatever origin this page is served from)
  - url: http://localhost:3000
    description: Local dev (npm run dev)
security:
  - BearerAuth: []
tags:
  - name: Characters
    description: AI presenter identities — clone a real person from selfies, or invent one from a description.
  - name: Voices
    description: Your voices, two kinds — clone a real voice from audio samples, or pick a ready-made library voice by language. Every voice_id must be one you created.
  - name: TTS
    description: Text-to-speech using one of your own cloned voices.
  - name: Images
    description: Still-frame generation for a character.
  - name: Videos
    description: Async video generation — `talking_head` (single-scene presenter) or `scenes` (multi-scene, your character acts the script out).
  - name: Jobs
    description: Poll async job status.
  - name: Assets
    description: The durable ledger of every output you've generated (sheets, frames, audio, video).
  - name: Usage
    description: Credit balance and job history.
paths:
  /api/partner/v1/characters:
    post:
      tags: [Characters]
      summary: Create a character (clone from selfies, or invent from a description)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 120 }
                selfie_image_urls:
                  type: array
                  items: { type: string, format: uri }
                  minItems: 1
                  maxItems: 3
                  description: Photo mode — 1-3 https selfie URLs. Reproduces the EXACT person.
                edit_instructions:
                  type: string
                  maxLength: 500
                  description: Photo mode only — a tweak applied while keeping the same identity (e.g. "younger", "curly hair").
                description:
                  type: string
                  maxLength: 2000
                  description: Description mode — invents a presenter from this free text.
                reference_image_urls:
                  type: array
                  items: { type: string, format: uri }
                  maxItems: 3
                  description: Description mode only — loose style inspiration, never an identity to copy.
                recast_attempt:
                  type: integer
                  minimum: 1
                  description: Description mode only — N>0 forces a noticeably different invented person.
              example:
                name: "Ava"
                description: "a friendly, upbeat presenter in her early 30s"
      responses:
        "200":
          description: Character created
          content:
            application/json:
              schema:
                type: object
                properties:
                  character_id: { type: string, format: uuid }
                  name: { type: string }
                  mode: { type: string, enum: [photos, description] }
                  sheet_image_url: { type: string, format: uri }
                  character_sheet_text: { type: string, nullable: true }
                  asset_id: { type: string, nullable: true }
                  credits_charged: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "422":
          description: Could not load any of the provided selfie images
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
    get:
      tags: [Characters]
      summary: List your characters
      responses:
        "200":
          description: Characters, newest first (max 100)
          content:
            application/json:
              schema:
                type: object
                properties:
                  characters:
                    type: array
                    items:
                      type: object
                      properties:
                        character_id: { type: string, format: uuid }
                        name: { type: string }
                        mode: { type: string, enum: [photos, description] }
                        sheet_image_url: { type: string, format: uri }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/characters/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Characters]
      summary: Get a character
      responses:
        "200":
          description: Character
          content:
            application/json:
              schema:
                type: object
                properties:
                  character_id: { type: string, format: uuid }
                  name: { type: string }
                  mode: { type: string, enum: [photos, description] }
                  sheet_image_url: { type: string, format: uri }
                  character_sheet_text: { type: string, nullable: true }
                  created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Characters]
      summary: Delete a character
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { type: object, properties: { deleted: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/voices:
    post:
      tags: [Voices]
      summary: Create a voice (clone from samples, or pick a library voice by language)
      description: |
        Provide exactly one source:

        **`sample_urls`** — clone a real voice from your audio samples. 30 credits. Capped
        per client (`PARTNER_MAX_VOICES_PER_CLIENT`, default 3) — clone slots are finite; delete
        an old cloned voice to free a slot.

        **`language`** — picks a high-quality ready-made voice for that language from the
        built-in voice library (verified usable with a tiny speech probe before it's saved).
        **Free**, and doesn't count against the clone cap (library voices have their own
        generous cap, default 10).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
                sample_urls:
                  type: array
                  items: { type: string, format: uri }
                  minItems: 1
                  maxItems: 25
                  description: Clone mode — public https audio URLs, each at least ~5 seconds.
                language:
                  type: string
                  description: 'Library mode — a language name or ISO code ("english", "british english", "arabic", "es", …).'
                gender:
                  type: string
                  enum: [female, male]
                  default: female
                  description: Library mode only — which voices to search.
            examples:
              clone:
                summary: Clone from audio samples (30 credits)
                value:
                  name: "Ava voice"
                  sample_urls: ["https://your-cdn.com/voice-sample.mp3"]
              library:
                summary: Pick a library voice by language (free)
                value:
                  name: "Spanish narrator"
                  language: "spanish"
                  gender: "female"
      responses:
        "200":
          description: Voice created
          content:
            application/json:
              schema:
                type: object
                properties:
                  voice_id: { type: string, description: "Opaque voice id" }
                  name: { type: string }
                  kind: { type: string, enum: [cloned, library] }
                  language: { type: string, nullable: true, description: "Library voices only." }
                  credits_charged: { type: integer, description: "30 for a clone, 0 for a library pick." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404":
          description: No library voice matched the requested language
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
        "409":
          description: Voice cap reached (cloned and library voices have separate caps)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "VOICE_LIMIT_REACHED" }
                  max_voices: { type: integer }
    get:
      tags: [Voices]
      summary: List your voices
      responses:
        "200":
          description: Voices, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  voices:
                    type: array
                    items:
                      type: object
                      properties:
                        voice_id: { type: string }
                        name: { type: string }
                        kind: { type: string, enum: [cloned, library] }
                        language: { type: string, nullable: true }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/voices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: The voice_id returned when the voice was created
    delete:
      tags: [Voices]
      summary: Delete a voice (for cloned voices this frees the clone slot)
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { type: object, properties: { deleted: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/tts:
    post:
      tags: [TTS]
      summary: Synthesize speech with one of your own voices
      description: |
        voice_id must belong to this client (404 otherwise). The speech engine is fixed — there
        is no model choice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [voice_id, text]
              properties:
                voice_id: { type: string }
                text: { type: string, maxLength: 5000 }
                language_code: { type: string, example: "en" }
                voice_settings:
                  type: object
                  description: Partial voice_settings override (stability, similarity_boost, style, speed, use_speaker_boost).
      responses:
        "200":
          description: Synthesized
          content:
            application/json:
              schema:
                type: object
                properties:
                  audio_url: { type: string, format: uri }
                  duration_sec: { type: number }
                  asset_id: { type: string, nullable: true }
                  credits_charged: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/images/first-frame:
    post:
      tags: [Images]
      summary: Generate a still image of a character from a scene description
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [character_id, scene_description]
              properties:
                character_id: { type: string, format: uuid }
                scene_description: { type: string, maxLength: 2000, description: "The \"video idea\" / what this image should show." }
      responses:
        "200":
          description: Image generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  image_url: { type: string, format: uri }
                  asset_id: { type: string, nullable: true }
                  credits_charged: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/videos:
    post:
      tags: [Videos]
      summary: Queue a video generation job (talking_head or scenes)
      description: |
        Async — poll `GET /api/partner/v1/jobs/{workflow_id}/status` until `completed`/`failed`.

        Price a request first with `POST /videos/estimate` (same body, free, no side effects).

        **`mode: "talking_head"`** — one continuous shot of the character presenting to camera,
        always finished with a studio lipsync pass (no lipsync knob). Audio source is exactly
        one of `{voice_id, script}` or `{audio_url}` (duration probed server-side). Audio must
        measure ≤ 59s. Optional dynamic captions (`use_dynamic_captions`).

        **`mode: "scenes"`** — a multi-scene video: your character acts the script out across
        several scenes, keeping the same face and voice, delivered as one continuous vertical
        video. Requires `voice_id` + `script` (no `audio_url`). Script must estimate ≤ 60.5s of
        narration. `use_dynamic_captions` is ignored for this mode.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode, character_id]
              properties:
                mode: { type: string, enum: [talking_head, scenes], example: "talking_head" }
                character_id: { type: string, format: uuid }
                voice_id: { type: string, description: "With script — synthesized inside the job. talking_head: provide this OR audio_url, not both. scenes: required." }
                script: { type: string, description: "Spoken verbatim, never trimmed. Caps: ≤59s measured (talking_head), ≤60.5s estimated (scenes)." }
                audio_url: { type: string, format: uri, description: "talking_head only — pre-recorded audio instead of voice_id+script; duration is probed server-side. Rejected for scenes." }
                video_idea: { type: string, maxLength: 2000, description: "talking_head: required unless first_frame_url is given. scenes: optional scene intent." }
                first_frame_url: { type: string, format: uri, description: "talking_head only — skip scene generation and use this still as the first frame." }
                resolution: { type: string, enum: [480p, 720p], default: "480p", description: "talking_head only — scenes always renders 720p vertical." }
                use_dynamic_captions:
                  type: boolean
                  description: "talking_head only — burn in dynamic captions (bold, word-by-word, on a solid legible band; renders Latin + Arabic script). Flat +10 credits when true. Ignored for scenes."
                captions_language:
                  type: string
                  example: "ar"
                  description: "talking_head only — optional speech-language hint for dynamic captions (e.g. en, ar). Omit to auto-detect."
                captions_position:
                  type: string
                  enum: [top, bottom]
                  default: "bottom"
                  description: "talking_head only — which edge the dynamic-caption band sits on; move it to keep captions off your subject."
                narration_mode:
                  type: string
                  enum: [auto, voiceover]
                  default: "auto"
                  description: "scenes only — auto decides on-camera speech vs narration per scene; voiceover forces narration everywhere."
                scene_elements:
                  type: array
                  maxItems: 6
                  description: "scenes only — real stills (product / setting / prop) woven into the video."
                  items:
                    type: object
                    required: [url]
                    properties:
                      url: { type: string, format: uri, description: "https image URL" }
                      role: { type: string, enum: [setting, product, prop] }
                      caption: { type: string, maxLength: 200 }
                fast:
                  type: boolean
                  default: false
                  description: "scenes only — fast tier: lower latency and ~25% cheaper per second, slightly lower quality."
                topic: { type: string, description: "scenes only — optional context." }
                audience: { type: string, description: "scenes only — optional context." }
                language: { type: string, description: "scenes only — optional spoken-language hint." }
            examples:
              talking_head:
                summary: Talking-head from a script
                value:
                  mode: "talking_head"
                  character_id: "00000000-0000-0000-0000-000000000000"
                  voice_id: "your_voice_id"
                  script: "Hi! Quick update on what's new this week."
                  video_idea: "standing in a bright modern kitchen, smiling at the camera"
                  resolution: "480p"
              scenes:
                summary: Multi-scene video (your character acts the script out)
                value:
                  mode: "scenes"
                  character_id: "00000000-0000-0000-0000-000000000000"
                  voice_id: "your_voice_id"
                  script: "Ever wondered how we roast our beans? Come on, I'll show you. This is where every batch starts — green, dense, and full of potential."
                  video_idea: "a barista giving a behind-the-scenes tour of a small coffee roastery"
                  narration_mode: "auto"
      responses:
        "200":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow_id: { type: string }
                  status: { type: string, example: "queued" }
                  credits_reserved: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429":
          description: Too many active jobs
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "TOO_MANY_ACTIVE_JOBS" }
                  max_active_jobs: { type: integer }

  /api/partner/v1/videos/estimate:
    post:
      tags: [Videos]
      summary: Price a video request without queueing it
      description: |
        Same body as `POST /videos`. Returns the credits that call would reserve, your live
        balance, and a line-item breakdown. Free, no side effects — nothing is charged, held, or
        queued. The referenced character/voice don't need to exist yet, so you can quote before
        setup; the only network work is the `audio_url` duration probe.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode, character_id]
              description: Identical to the POST /videos request body.
              properties:
                mode: { type: string, enum: [talking_head, scenes] }
      responses:
        "200":
          description: Quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  mode: { type: string, enum: [talking_head, scenes] }
                  credits_required: { type: integer }
                  credits_balance: { type: integer }
                  sufficient_credits: { type: boolean }
                  estimated_seconds: { type: number }
                  breakdown:
                    type: object
                    description: "talking_head: render/lipsync/tts/first_frame/captions credits. scenes: render_credits, tts_credits."
              example:
                mode: "talking_head"
                credits_required: 256
                credits_balance: 961
                sufficient_credits: true
                estimated_seconds: 7.4
                breakdown: { render_credits: 60, lipsync_credits: 99, tts_credits: 10, first_frame_credits: 6, captions_credits: 0 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: audio_url could not be probed
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }

  /api/partner/v1/jobs/{workflowId}/status:
    parameters:
      - name: workflowId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Jobs]
      summary: Poll a job's status
      responses:
        "200":
          description: Job status
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow_id: { type: string }
                  status: { type: string, enum: [queued, running, completed, failed] }
                  error_message: { type: string, nullable: true }
                  credits_estimated: { type: integer, nullable: true }
                  credits_actual: { type: integer, nullable: true }
                  credits_consumed:
                    type: integer
                    nullable: true
                    description: "null while queued/running; actual_credits (falling back to estimated_credits) once completed; 0 on failed unless a partial actual_credits was recorded."
                  image_url: { type: string, nullable: true }
                  video_url: { type: string, nullable: true }
                  result:
                    type: object
                    nullable: true
                    description: "Generic result envelope ({video_url?, image_url?, audio_url?, asset_id?})."
                  queued_at: { type: string, format: date-time }
                  completed_at: { type: string, nullable: true, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/assets:
    get:
      tags: [Assets]
      summary: List everything you've generated
      parameters:
        - name: kind
          in: query
          schema: { type: string, enum: [character_sheet, first_frame, tts_audio, video] }
        - name: limit
          in: query
          schema: { type: integer, default: 30, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Assets, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  assets:
                    type: array
                    items: { $ref: "#/components/schemas/Asset" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/assets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Assets]
      summary: Get one asset
      responses:
        "200":
          description: Asset
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Asset" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/usage/summary:
    get:
      tags: [Usage]
      summary: Credit balance and usage in a date range
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date-time }
          description: "Defaults to the first instant of the current UTC month."
        - name: to
          in: query
          schema: { type: string, format: date-time }
          description: "Defaults to now."
      responses:
        "200":
          description: Summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  credits_balance: { type: integer }
                  credits_balance_base: { type: integer }
                  credits_spent_lifetime: { type: integer, description: "Spend since the last top-up (base resets on each grant), not all-time." }
                  credits_consumed_in_range: { type: integer }
                  job_counts:
                    type: object
                    properties:
                      completed: { type: integer }
                      failed: { type: integer }
                      running_or_queued: { type: integer }
                      total_in_range: { type: integer }
                  from: { type: string, format: date-time }
                  to: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/usage/jobs:
    get:
      tags: [Usage]
      summary: Paginated job history
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 30, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Jobs, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items:
                      type: object
                      properties:
                        workflow_id: { type: string }
                        status: { type: string }
                        credits_estimated: { type: integer, nullable: true }
                        credits_actual: { type: integer, nullable: true }
                        queued_at: { type: string, format: date-time }
                        completed_at: { type: string, nullable: true }
                        error_message: { type: string, nullable: true }
                        metadata: { type: object }
                        job_type: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "pmat_<12 hex>_<48 hex>"
      description: Full partner API key, e.g. `pmat_1a2b3c4d5e6f_...`
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
    Asset:
      type: object
      properties:
        asset_id: { type: string, format: uuid }
        kind: { type: string, enum: [character_sheet, first_frame, tts_audio, video] }
        url: { type: string, format: uri }
        workflow_id: { type: string, nullable: true }
        character_id: { type: string, nullable: true }
        voice_id: { type: string, nullable: true }
        metadata: { type: object }
        created_at: { type: string, format: date-time }
  responses:
    BadRequest:
      description: Validation error
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    NotFound:
      description: Not found (also returned when the resource belongs to a different client — existence is never leaked)
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    InsufficientCredits:
      description: Not enough credits
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: "INSUFFICIENT_CREDITS" }
              credits_required: { type: integer }
              credits_balance: { type: integer }
