openapi: 3.1.0
info:
  title: Compeller v1 API
  version: "1.0.0"
  description: |
    Agent-first REST API for the Compeller platform.

    This spec is the source of truth for the implemented `/api/v1/*` surface. The HTML
    landing page at `/api/docs` is a marketing page and is not authoritative — integrate
    against this spec.

    ## Per-song end-to-end flow

    1. `POST /api/v1/media` — upload the audio track as `multipart/form-data`. The response
       contains a numeric media `id`.
    2. `POST /api/v1/compels` — create a compel with `primary_media_id` set to that id.
       Returns `id`, `status: QUEUED`, `stage`.
    3. `GET /api/v1/compels/{id}` — poll until `status == COMPLETED`. When it is, the
       payload contains a `rendering_id`.
    4. `GET /api/v1/renderings/{id}/download` — stream the finished MP4. Range requests
       are supported.

    ## Authentication

    Every authenticated operation accepts **either**:
    - `Authorization: Bearer <api-token>`
    - `X-API-Token: <api-token>`

    Tokens are issued against a Compeller `User` (per-account). Agents can obtain a token
    by asking the user to paste one from the dashboard, by calling
    `POST /api/v1/auth/token`, or by using the existing Spring REST login endpoint
    `POST /api/login`. Both the dashboard API token and the `/api/login` `access_token`
    are accepted by authenticated v1 and MCP calls.

    ## Errors

    Error responses use RFC 7807 `application/problem+json` on the compel and rendering
    routes. Legacy media routes return plain JSON `{"message": "..."}`; this is a known
    inconsistency that will be normalized in a future cut.

    ## Webhooks

    Register an HTTPS endpoint via `POST /api/v1/webhooks` to receive signed push
    notifications for compel terminal events (`compel.completed`, `compel.failed`). The
    create response returns `secret` exactly once — store it, it will never be shown again.

    Every delivery includes these headers:

    - `X-Compeller-Signature: sha256=<hex>` — HMAC-SHA256 of the raw request body, keyed
      by the endpoint's `secret`. Verify this before trusting the payload.
    - `X-Compeller-Timestamp: <epoch-ms>` — server-side emit time (not currently part of
      the signature; reserved for future replay protection).
    - `X-Compeller-Event-Id: evt_<uuid>` — stable event identifier; safe to use as an
      idempotency key.
    - `X-Compeller-Event-Type: compel.completed` (or `compel.failed`).

    Delivery is fire-and-forget with a 5-second timeout; there is no retry in this cut.
    Integrators who miss an event should reconcile via `GET /api/v1/compels/{id}`.

servers:
  - url: https://compeller.ai
    description: Production
  - url: https://dev.compeller.ai
    description: Development

security:
  - bearerToken: []
  - apiTokenHeader: []

tags:
  - name: Discovery
    description: Static capability, style, pricing, and format metadata.
  - name: Auth
    description: Credential exchange for retrieving a user's API token.
  - name: Media
    description: Upload and retrieve account-owned media (audio, image, video, text).
  - name: Compels
    description: Create and track generation jobs.
  - name: Renderings
    description: List, inspect, and download completed video renderings for a compel.
  - name: Webhooks
    description: Register HTTPS endpoints for signed push notifications on compel terminal events.

paths:
  /api/v1/capabilities:
    get:
      tags: [Discovery]
      operationId: getCapabilities
      summary: List platform capabilities.
      security: []
      responses:
        "200":
          description: Capability descriptor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CapabilitiesResponse"

  /api/v1/openapi.yaml:
    get:
      tags: [Discovery]
      operationId: getOpenApiSpec
      summary: Fetch this OpenAPI 3.1 spec as served by the running product.
      description: |
        Returns the byte-for-byte contents of `docs/api/openapi.yaml` from the
        deployed build. The `/api/v1/capabilities` response advertises this
        path in its `spec_url` field. Cached for 5 minutes.
      security: []
      responses:
        "200":
          description: OpenAPI 3.1 specification document.
          content:
            application/yaml:
              schema:
                type: string
        "500":
          $ref: "#/components/responses/Problem"
    head:
      tags: [Discovery]
      operationId: headOpenApiSpec
      summary: Probe metadata for the OpenAPI spec without fetching its body.
      description: |
        Same `Content-Type` and `Cache-Control` as the GET response, but with
        no body. Intended for tools (OpenAPI Generator, Stainless, Postman)
        that probe before downloading.
      security: []
      responses:
        "200":
          description: Headers-only acknowledgement.
        "500":
          $ref: "#/components/responses/Problem"

  /api/login:
    post:
      tags: [Auth]
      operationId: springRestLoginForAccessToken
      summary: Existing login endpoint that returns an access_token accepted by v1 and MCP calls.
      description: |
        Existing Spring Security REST login endpoint. Agents may call this with the user's
        Compeller username/password to retrieve an `access_token`, then send that value as
        `Authorization: Bearer <access_token>` on `/api/v1/*` and `/api/mcp` requests.
        Do not send or depend on a browser/Postman `Cookie` header; JSON credentials are
        sufficient and the cookie is not part of the agent contract.

        The response includes `username` and `access_token`. `roles` is included only
        when the account has elevated roles beyond baseline `ROLE_COMPELLER`.
        `refresh_token` and `expires_in` are included only when the token backend returns them.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginRequest"
            examples:
              login:
                value:
                  username: artist@example.com
                  password: correct-horse-battery-staple
      responses:
        "200":
          description: Spring REST login token response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoginResponse"
        "401":
          $ref: "#/components/responses/Problem"

  /api/v1/auth/token:
    post:
      tags: [Auth]
      operationId: createApiTokenFromCredentials
      summary: Login with Compeller credentials and return the user's API token.
      description: |
        Agent-friendly credential exchange. Submit the user's Compeller email and password
        over HTTPS. The response returns the existing opaque API token used by both REST
        (`Authorization: Bearer <token>` or `X-API-Token`) and MCP (`/api/mcp`).

        This endpoint does not create a browser session or a new JWT. Treat the returned
        token like a password; store it in the caller's secret store and never paste it
        into public chat/logs.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuthTokenRequest"
            examples:
              login:
                value:
                  email: artist@example.com
                  password: correct-horse-battery-staple
      responses:
        "200":
          description: API token and usage headers.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokenResponse"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"

  /api/v1/login:
    post:
      tags: [Auth]
      operationId: loginForApiToken
      summary: Alias for POST /api/v1/auth/token.
      description: Compatibility alias for clients that discover a conventional login path.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuthTokenRequest"
      responses:
        "200":
          description: API token and usage headers.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokenResponse"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"

  /api/v1/auth/signup:
    post:
      tags: [Auth]
      operationId: createAgentSignup
      summary: Self-serve agent signup. Creates one Compeller account and returns an access_token.
      description: |
        Lets a fresh external agent create a Compeller account and receive an
        immediately-usable bearer token without any out-of-band setup. Email
        verification is deferred -- the new account starts with the FREE-plan
        minute allowance and is gated by the existing `Account.isEligibleForApi`
        rules, not by an email-verified flag.

        **Required header:** `User-Agent` (so signups can be attributed to a
        specific agent build).

        **Recommended header:** `X-Agent-Installation-Id` -- a stable opaque
        identifier per installation of your agent (8-128 chars, `[A-Za-z0-9_-]`).
        It is treated as the agent equivalent of a browser fingerprint:
        repeated signups with the same installation id from one IP will be
        throttled with `HTTP 429 application/problem+json`, but already-issued
        tokens are unaffected.

        Sign up **once** and persist the returned token. Repeated signups with
        the same email return `HTTP 409 application/problem+json` with a hint
        to call `POST /api/v1/auth/token` instead.
      security: []
      parameters:
        - in: header
          name: User-Agent
          required: true
          schema: { type: string }
          example: "MyAgent/1.2.3 (+https://example.com)"
        - in: header
          name: X-Agent-Installation-Id
          required: false
          schema:
            type: string
            pattern: "^[A-Za-z0-9_-]{8,128}$"
          description: Stable per-installation UUID. Sent on every signup retry from the same install.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentSignupRequest"
            examples:
              minimal:
                value:
                  email: agent@example.com
                  agent_name: MyAgent
              with-password-and-contact:
                value:
                  email: agent@example.com
                  agent_name: MyAgent
                  password: correct-horse-battery-staple
                  contact_email: ops@example.com
                  accept_terms: true
      responses:
        "201":
          description: Account + user created; token is immediately usable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentSignupResponse"
        "400":
          $ref: "#/components/responses/Problem"
        "409":
          description: Email already registered. Use POST /api/v1/auth/token to log in instead.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: Too many signup attempts from this IP or installation id. Existing tokens are unaffected.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"

  /api/v1/styles:
    get:
      tags: [Discovery]
      operationId: listStyles
      summary: List available visual styles.
      security: []
      responses:
        "200":
          description: Style descriptor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StylesResponse"

  /api/v1/pricing:
    get:
      tags: [Discovery]
      operationId: getPricing
      summary: List published subscription plans.
      security: []
      responses:
        "200":
          description: Pricing descriptor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PricingResponse"

  /api/v1/input-formats:
    get:
      tags: [Discovery]
      operationId: listInputFormats
      summary: Describe accepted input formats.
      security: []
      responses:
        "200":
          description: Input format descriptor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InputFormatsResponse"

  /api/v1/rendering-formats:
    get:
      tags: [Discovery]
      operationId: listRenderingFormats
      summary: Describe supported rendering formats (aspect ratios and platform targets).
      security: []
      responses:
        "200":
          description: Rendering format descriptor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenderingFormatsResponse"

  /api/v1/media/upload-url:
    post:
      tags: [Media]
      operationId: requestMediaUploadUrl
      summary: Get an upload contract describing how and where to POST a media file.
      description: |
        Returns a descriptor pointing at `POST /api/v1/media`. There is no pre-signed
        object-store URL today — the actual upload target is this same server, and the
        descriptor's `upload_url` field is the literal string `/api/v1/media`. The
        descriptor exists so clients can be coded against a contract that can later move
        to a signed S3/GCS URL without code changes.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MediaUploadUrlRequest"
      responses:
        "200":
          description: Upload contract.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaUploadUrlResponse"
        "401":
          $ref: "#/components/responses/UnauthorizedMediaStyle"

  /api/v1/media:
    post:
      tags: [Media]
      operationId: uploadMedia
      summary: Upload a media file (audio, image, video, or text).
      description: |
        Submit as `multipart/form-data`. The `file` part is required. Other fields are
        optional query-string or form parameters. Audio should be submitted as `audio`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: "#/components/schemas/MediaUploadForm"
            encoding:
              file:
                contentType: application/octet-stream
      parameters:
        - name: type
          in: query
          description: Media type override. Values `audio`, `image`, `video`, `text`.
          required: false
          schema: { type: string }
        - name: media_type
          in: query
          description: Alias for `type`.
          required: false
          schema: { type: string }
        - name: workspace_id
          in: query
          description: Compel id to attach the media to (alias `compel_id`).
          required: false
          schema: { type: integer, format: int64 }
        - name: compel_id
          in: query
          description: Alias for `workspace_id`.
          required: false
          schema: { type: integer, format: int64 }
      responses:
        "201":
          description: Media record created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaEnvelope"
        "400":
          $ref: "#/components/responses/BadRequestMediaStyle"
        "401":
          $ref: "#/components/responses/UnauthorizedMediaStyle"
        "422":
          $ref: "#/components/responses/UnprocessableMediaStyle"

    get:
      tags: [Media]
      operationId: listMedia
      summary: List media records owned by the authenticated account.
      parameters:
        - name: max
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
        - name: type
          in: query
          schema: { type: string }
        - name: media_type
          in: query
          schema: { type: string }
        - name: workspace_id
          in: query
          schema: { type: integer, format: int64 }
        - name: compel_id
          in: query
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: Paged media list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaListResponse"
        "401":
          $ref: "#/components/responses/UnauthorizedMediaStyle"

  /api/v1/media/{id}:
    get:
      tags: [Media]
      operationId: getMedia
      summary: Fetch a single media record by id.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Media envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaEnvelope"
        "401":
          $ref: "#/components/responses/UnauthorizedMediaStyle"
        "404":
          description: Not found or not owned by the authenticated account. Empty body.

  /api/v1/media/{id}/download:
    get:
      tags: [Media]
      operationId: downloadMedia
      summary: Stream a media file.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Media file bytes. `Content-Type` reflects the stored mime type.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401":
          $ref: "#/components/responses/UnauthorizedMediaStyle"
        "404":
          description: Not found or not owned by the authenticated account. Empty body.

  /api/v1/compels:
    post:
      tags: [Compels]
      operationId: createCompel
      summary: Create a compel generation job.
      description: |
        The primary media record referenced by `primary_media_id` must be owned by the
        authenticated account and must be an audio record. The request returns `202
        Accepted` and the job is enqueued for processing.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCompelRequest"
      responses:
        "202":
          description: Compel created and queued for processing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompelPayload"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"

    get:
      tags: [Compels]
      operationId: listCompels
      summary: List compels owned by the authenticated account, newest first.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: max
          in: query
          description: Alias for `limit`.
          schema: { type: integer, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: Paged compel list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompelListResponse"
        "401":
          $ref: "#/components/responses/Problem"

  /api/v1/compels/{id}:
    get:
      tags: [Compels]
      operationId: getCompel
      summary: Fetch a single compel by id.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Compel envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompelEnvelope"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

    delete:
      tags: [Compels]
      operationId: deleteCompel
      summary: Cancel a compel.
      description: |
        Behaviourally equivalent to `POST /api/v1/compels/{id}/cancel` — in-flight work is
        cancelled and the compel transitions to `CANCELLED`. Exposed as `DELETE` for
        REST-shaped clients; see the `cancel` alias for clients that prefer explicit POST.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "202":
          description: Cancellation accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompelCancelResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"

  /api/v1/compels/{id}/cancel:
    post:
      tags: [Compels]
      operationId: cancelCompel
      summary: Cancel a compel (explicit POST alias for `DELETE /api/v1/compels/{id}`).
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "202":
          description: Cancellation accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompelCancelResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"

  /api/v1/compels/{id}/events:
    get:
      tags: [Compels]
      operationId: listCompelEvents
      summary: List lifecycle events for a compel.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Event timeline for the compel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompelEventsResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

  /api/v1/compels/{id}/renderings:
    get:
      tags: [Renderings]
      operationId: listRenderingsForCompel
      summary: List all renderings for a compel.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Rendering list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenderingListResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

  /api/v1/renderings/{id}:
    get:
      tags: [Renderings]
      operationId: getRendering
      summary: Fetch a single rendering by id.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Rendering envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenderingEnvelope"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

  /api/v1/renderings/{id}/download:
    get:
      tags: [Renderings]
      operationId: downloadRendering
      summary: Stream the rendered video file.
      description: |
        Returns the rendered MP4 bytes. Supports HTTP range requests — send a
        `Range: bytes=<start>-<end>` header to receive `206 Partial Content`. `409
        Conflict` is returned when the rendering exists but is not yet `COMPLETED` or
        the file is missing from storage — clients should poll until the corresponding
        `/renderings/{id}` reports `status == COMPLETED`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: Range
          in: header
          description: Optional byte range.
          required: false
          schema: { type: string, example: "bytes=0-1048575" }
      responses:
        "200":
          description: Full file.
          headers:
            Accept-Ranges:
              schema: { type: string, example: bytes }
            Content-Length:
              schema: { type: integer, format: int64 }
            Content-Disposition:
              schema: { type: string, example: 'attachment; filename="rendering.mp4"' }
          content:
            video/mp4:
              schema: { type: string, format: binary }
        "206":
          description: Partial content for a satisfied `Range` request.
          headers:
            Content-Range:
              schema: { type: string, example: "bytes 0-1048575/12345678" }
            Accept-Ranges:
              schema: { type: string, example: bytes }
            Content-Length:
              schema: { type: integer, format: int64 }
          content:
            video/mp4:
              schema: { type: string, format: binary }
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "416":
          description: Range not satisfiable.
          headers:
            Content-Range:
              schema: { type: string, example: "bytes */12345678" }

  /api/v1/webhooks:
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a webhook endpoint.
      description: |
        Registers an HTTPS endpoint to receive `compel.completed` and `compel.failed`
        events for the caller's account. The response includes a `secret` that is only
        ever returned on this call — store it immediately; there is no rotate-secret
        endpoint in this cut.

        **URL safety.** Destinations that point at internal infrastructure are rejected
        with `422 Invalid url`:
        - loopback (`127.0.0.0/8`, `::1`) and `localhost`
        - RFC1918 private ranges (`10/8`, `172.16/12`, `192.168/16`)
        - link-local (`169.254/16` — includes `169.254.169.254` cloud metadata,
          `fe80::/10`)
        - IPv6 unique-local `fc00::/7`
        - IPv4 CGNAT `100.64/10`
        - the unspecified address (`0.0.0.0`, `::`) and multicast
        - hostnames ending in `.local`, `.internal`, `.localhost`

        The same checks re-run at delivery time against resolved DNS, so a hostname
        that resolves to a blocked IP after registration is silently skipped (logged,
        not retried).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookCreateRequest"
      responses:
        "201":
          description: Webhook created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookCreateResponse"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"

    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List registered webhooks for the caller's account.
      responses:
        "200":
          description: Webhook list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookList"
        "401":
          $ref: "#/components/responses/Problem"

  /api/v1/webhooks/{id}:
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Fetch a single webhook by id (secret is redacted).
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Webhook envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEnvelope"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update mutable fields on a webhook (url, events, active).
      description: |
        Partial update. Any of `url`, `events`, `active` may be provided; at
        least one is required. URL safety checks from registration apply —
        destinations pointing at internal infrastructure are rejected with
        `422 Invalid url`. The response redacts the secret; use
        `POST /api/v1/webhooks/{id}/rotate-secret` to mint a new one.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookUpdateRequest"
      responses:
        "200":
          description: Updated webhook (secret redacted).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEnvelope"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"

    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204":
          description: Webhook deleted.
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

  /api/v1/webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Mint a new HMAC signing secret (returned exactly once).
      description: |
        Generates a fresh 64-char hex secret, stores it on the endpoint, and
        returns it in the response. The previous secret is invalidated
        immediately — integrators must update their receiver before the next
        compel terminal event fires. There is no separate "confirm" step.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Webhook with the newly minted secret.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookCreateResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

  /api/v1/webhooks/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Synchronously deliver a synthetic `webhook.test` event to this endpoint.
      description: |
        Sends one HMAC-signed test POST to the endpoint's URL and returns the result
        synchronously. Uses the same signature header (`X-Compeller-Signature`) and
        URL safety checks as real deliveries, so a URL that now resolves to a
        blocked address returns `delivered: false` with an `error` field instead of
        being sent.

        Does **not** filter by the endpoint's `events` subscription — the test is
        always delivered regardless of what the endpoint is subscribed to.

        A non-2xx response from the endpoint is surfaced as `delivered: false` but
        the HTTP call to this API still returns `200 OK` — the test result itself
        is the payload. Only auth and not-found errors produce RFC 7807 4xx.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Test delivery result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookTestResult"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"

components:
  securitySchemes:
    bearerToken:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: "Compeller API token sent as `Authorization: Bearer <token>`."
    apiTokenHeader:
      type: apiKey
      in: header
      name: X-API-Token
      description: "Compeller API token sent as `X-API-Token: <token>` (equivalent to bearer)."

  parameters:
    PathId:
      name: id
      in: path
      required: true
      schema:
        type: integer
        format: int64

  responses:
    Problem:
      description: RFC 7807 problem detail.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    UnauthorizedMediaStyle:
      description: Missing or invalid API token (legacy media-controller error shape).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/MessageError"
    BadRequestMediaStyle:
      description: Malformed request (legacy media-controller error shape).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/MessageError"
    UnprocessableMediaStyle:
      description: Request understood but media service could not persist it.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/MessageError"

  schemas:
    Problem:
      type: object
      description: RFC 7807 problem detail.
      required: [type, title, status]
      properties:
        type:
          type: string
          example: about:blank
        title:
          type: string
        status:
          type: integer
        detail:
          type: string

    MessageError:
      type: object
      required: [message]
      properties:
        message:
          type: string

    # --------- Auth ---------

    LoginRequest:
      type: object
      required: [username, password]
      properties:
        username:
          type: string
          format: email
          example: artist@example.com
        password:
          type: string
          format: password
          writeOnly: true

    LoginResponse:
      type: object
      required: [username, access_token]
      properties:
        username:
          type: string
          format: email
        roles:
          type: array
          description: Included only when the user has roles beyond baseline ROLE_COMPELLER.
          items: { type: string }
        access_token:
          type: string
          description: "Token accepted as `Authorization: Bearer <access_token>` on v1 REST and MCP calls."
        refresh_token:
          type: string
          nullable: true
          description: Included only when the login backend returns a refresh token.
        expires_in:
          type: integer
          nullable: true
          description: Included only when the login backend returns an expiration.

    AuthTokenRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          description: Compeller account email. `username` is also accepted as an alias.
          example: artist@example.com
        username:
          type: string
          description: Optional alias for `email`.
          example: artist@example.com
        password:
          type: string
          format: password
          writeOnly: true

    AuthTokenResponse:
      type: object
      required: [apiToken, tokenType, header, alternateHeader, user]
      properties:
        apiToken:
          type: string
          description: Opaque Compeller API token. Use as a bearer token or X-API-Token.
          example: 0123456789abcdef0123456789abcdef
        tokenType:
          type: string
          enum: [Bearer]
        header:
          type: string
          example: "Authorization: Bearer 0123456789abcdef0123456789abcdef"
        alternateHeader:
          type: string
          example: "X-API-Token: 0123456789abcdef0123456789abcdef"
        user:
          type: object
          required: [id, email, accountId]
          properties:
            id: { type: integer, format: int64 }
            email: { type: string, format: email }
            displayName: { type: string }
            accountId: { type: integer, format: int64 }
            accountStatus: { type: string }
            apiEligible:
              type: boolean
              description: Whether this account is currently eligible to run authenticated API jobs.

    AgentSignupRequest:
      type: object
      required: [email, agent_name]
      properties:
        email:
          type: string
          format: email
          example: agent@example.com
        agent_name:
          type: string
          minLength: 2
          maxLength: 128
          description: Human-readable agent or product name. `product_name` is also accepted as an alias.
          example: MyAgent
        product_name:
          type: string
          description: Alias for `agent_name`.
        password:
          type: string
          format: password
          minLength: 12
          writeOnly: true
          description: Optional. If omitted, a strong password is generated and discarded; reuse the returned token instead.
        contact_email:
          type: string
          format: email
          description: Optional secondary contact (e.g. ops@example.com). Recorded as an additional account identifier.
        accept_terms:
          type: boolean
          description: Optional acknowledgement of Compeller Terms of Service. Echoed back in the response.
        installation_id:
          type: string
          pattern: "^[A-Za-z0-9_-]{8,128}$"
          description: Optional body fallback for `X-Agent-Installation-Id` when headers cannot be set. Header takes precedence.

    AgentSignupResponse:
      type: object
      required: [access_token, apiToken, tokenType, header, alternateHeader, user, signup]
      properties:
        access_token:
          type: string
          description: Bearer token usable on `/api/v1/*` and `/api/mcp`. Same value as `apiToken`.
        apiToken:
          type: string
          description: Alias of `access_token` for parity with POST /api/v1/auth/token.
        tokenType:
          type: string
          enum: [Bearer]
        header:
          type: string
          example: "Authorization: Bearer 0123456789abcdef0123456789abcdef"
        alternateHeader:
          type: string
          example: "X-API-Token: 0123456789abcdef0123456789abcdef"
        user:
          type: object
          required: [id, email, accountId]
          properties:
            id: { type: integer, format: int64 }
            email: { type: string, format: email }
            displayName: { type: string }
            accountId: { type: integer, format: int64 }
            accountStatus: { type: string }
            apiEligible: { type: boolean }
        signup:
          type: object
          required: [verification, account_status]
          properties:
            verification:
              type: string
              enum: [deferred]
              description: Email verification is not required for token issuance in this slice.
            terms_accepted: { type: boolean }
            account_status: { type: string, example: ACTIVE }
            agent_installation_id:
              type: string
              nullable: true
              description: Echo of the X-Agent-Installation-Id header (or body fallback) when one was supplied.
            guidance:
              type: string
              description: Plain-language reminder to persist the token and not call signup repeatedly.

    # --------- Discovery ---------

    CapabilitiesResponse:
      type: object
      required: [productName, version, capabilities, spec_url]
      properties:
        productName: { type: string, example: Compeller }
        version: { type: string, example: "1" }
        capabilities:
          type: array
          items: { type: string }
          example: [music_video_generation, promo_clip_generation, compel_search]
        spec_url:
          type: string
          description: Path at which this OpenAPI spec is served by the running product.
          example: /api/v1/openapi.yaml

    StylesResponse:
      type: object
      required: [productName, version, styles]
      properties:
        productName: { type: string }
        version: { type: string }
        styles:
          type: array
          items:
            $ref: "#/components/schemas/StyleDescriptor"

    StyleDescriptor:
      type: object
      required: [id, name, description]
      properties:
        id: { type: string, example: cinematic }
        name: { type: string, example: Cinematic }
        description: { type: string }

    PricingResponse:
      type: object
      required: [productName, version, plans]
      properties:
        productName: { type: string }
        version: { type: string }
        plans:
          type: array
          items:
            $ref: "#/components/schemas/PlanDescriptor"

    PlanDescriptor:
      type: object
      required: [id, name, monthlyUsd, features]
      properties:
        id: { type: string, example: pro }
        name: { type: string, example: Pro }
        monthlyUsd: { type: integer }
        features:
          type: array
          items: { type: string }

    InputFormatsResponse:
      type: object
      required: [productName, version, inputFormats]
      properties:
        productName: { type: string }
        version: { type: string }
        inputFormats:
          type: array
          items:
            $ref: "#/components/schemas/InputFormatDescriptor"

    InputFormatDescriptor:
      type: object
      required: [type, formats]
      properties:
        type: { type: string, example: audio_file }
        formats:
          type: array
          items: { type: string }

    RenderingFormatsResponse:
      type: object
      required: [productName, version, renderingFormats]
      properties:
        productName: { type: string }
        version: { type: string }
        renderingFormats:
          type: array
          items:
            $ref: "#/components/schemas/RenderingFormatDescriptor"

    RenderingFormatDescriptor:
      type: object
      required: [id, aspectRatio, targets]
      properties:
        id: { type: string, example: vertical_9_16 }
        aspectRatio: { type: string, example: "9:16" }
        targets:
          type: array
          items: { type: string }

    # --------- Media ---------

    MediaUploadUrlRequest:
      type: object
      properties:
        name: { type: string, example: track.mp3 }
        file_name: { type: string, description: Alias for `name`. }
        mime_type: { type: string, example: audio/mpeg }
        mimeType: { type: string, description: Alias for `mime_type`. }
        type:
          type: string
          enum: [audio, image, video, text]
        media_type:
          type: string
          description: Alias for `type`.
        role:
          type: string
          enum: [primary, reference]
        tags:
          oneOf:
            - type: array
              items: { type: string }
            - type: string
              description: Comma-separated tags.
        workspace_id: { type: integer, format: int64 }
        compel_id: { type: integer, format: int64, description: Alias for `workspace_id`. }
        duration_ms: { type: integer, format: int64 }

    MediaUploadUrlResponse:
      type: object
      required: [upload_url, method, expires_at, headers, fields, media]
      properties:
        upload_url:
          type: string
          description: The literal string `/api/v1/media` — not a signed external URL.
          example: /api/v1/media
        method:
          type: string
          enum: [POST]
        expires_at:
          type: string
          format: date-time
        headers:
          type: object
          additionalProperties: { type: string }
        fields:
          type: object
          additionalProperties: true
        media:
          $ref: "#/components/schemas/MediaDescriptorPlaceholder"

    MediaDescriptorPlaceholder:
      type: object
      description: "Placeholder media descriptor with `id: null` and `status: UPLOADING`."
      properties:
        id: { type: [integer, "null"], format: int64 }
        workspace_id: { type: [integer, "null"], format: int64 }
        type: { type: string }
        role: { type: string }
        mime_type: { type: string }
        name: { type: string }
        status: { type: string, example: UPLOADING }
        duration_ms: { type: [integer, "null"], format: int64 }
        tags:
          type: array
          items: { type: string }
        download_url: { type: [string, "null"] }

    MediaUploadForm:
      type: object
      required: [file]
      properties:
        file:
          type: string
          format: binary
          description: The media file bytes.
        type:
          type: string
          description: Optional override, mirrors the `type` query parameter.
        media_type:
          type: string
          description: Alias for `type`.
        workspace_id:
          type: integer
          format: int64
        compel_id:
          type: integer
          format: int64

    MediaEnvelope:
      type: object
      required: [media]
      properties:
        media:
          $ref: "#/components/schemas/Media"

    MediaListResponse:
      type: object
      required: [media, paging]
      properties:
        media:
          type: array
          items:
            $ref: "#/components/schemas/Media"
        paging:
          $ref: "#/components/schemas/Paging"

    Media:
      type: object
      required: [id, type, status, download_url]
      properties:
        id: { type: integer, format: int64 }
        workspace_id: { type: [integer, "null"], format: int64 }
        type:
          type: string
          enum: [audio, image, video, text]
        role:
          type: string
          example: primary
        mime_type: { type: string }
        name: { type: string }
        status:
          type: string
          enum: [UPLOADING, PROCESSING, READY, FAILED]
        duration_ms: { type: [integer, "null"], format: int64 }
        tags:
          type: array
          items: { type: string }
        download_url:
          type: string
          description: Relative URL, prefix with the server base to fetch.
          example: /api/v1/media/55/download

    # --------- Compels ---------

    CreateCompelRequest:
      type: object
      required: [title, primary_media_id]
      properties:
        title: { type: string }
        primary_media_id: { type: integer, format: int64 }
        reference_media_ids:
          type: array
          items: { type: integer, format: int64 }
        style:
          type: string
          example: cinematic
        target_platform:
          type: string
          description: Drives default aspect ratio when `aspect_ratio` is omitted.
          example: tiktok
        aspect_ratio:
          type: string
          example: "9:16"
        artist_context: { type: string }
        mode: { type: string }
        duration_preference: { type: string }

    CompelPayload:
      type: object
      required: [id, title, status, progress, stage, retry_count, eta_seconds, created_at]
      properties:
        id: { type: integer, format: int64 }
        title: { type: string }
        status:
          $ref: "#/components/schemas/CompelStatus"
        progress:
          type: integer
          minimum: 0
          maximum: 100
        progress_percent:
          type: integer
          description: Alias for `progress`.
        stage:
          $ref: "#/components/schemas/CompelStage"
        retry_count:
          type: integer
        estimated_completion:
          type: [string, "null"]
          format: date-time
        eta_seconds:
          type: integer
          format: int64
        rendering_id:
          type: [integer, "null"]
          format: int64
          description: Populated when a COMPLETED rendering exists.
        created_at:
          type: [string, "null"]
          format: date-time

    CompelEnvelope:
      type: object
      required: [compel]
      properties:
        compel:
          $ref: "#/components/schemas/CompelPayload"

    CompelListResponse:
      type: object
      required: [compels, paging]
      properties:
        compels:
          type: array
          items:
            $ref: "#/components/schemas/CompelEnvelope"
        paging:
          $ref: "#/components/schemas/Paging"

    CompelStatus:
      type: string
      description: Public-facing compel status derived from internal state.
      enum:
        - QUEUED
        - VALIDATING
        - PROCESSING
        - RENDERING
        - COMPLETED
        - CANCELLED
        - FAILED_RETRYING
        - FAILED_FINAL

    CompelStage:
      type: string
      description: Internal pipeline stage exposed for observability. Do not branch on it.
      enum:
        - CREATED
        - AUDIO_UPLOAD
        - AUDIO_PROCESSING
        - AUDIO_ANALYSIS
        - AUDIO_TRANSCRIBE
        - BACKDROP_RENDER
        - READY
        - SCENES_CREATED
        - COMPEL_RENDERED
        - FAILED
        - CANCELLED
        - PENDING_ASSETS

    CompelCancelResponse:
      type: object
      required: [id, status, stage]
      properties:
        id: { type: integer, format: int64 }
        status:
          type: string
          enum: [CANCELLED]
        stage:
          $ref: "#/components/schemas/CompelStage"

    CompelEventsResponse:
      type: object
      required: [compel_id, status, stage, events]
      properties:
        compel_id: { type: integer, format: int64 }
        status:
          $ref: "#/components/schemas/CompelStatus"
        stage:
          $ref: "#/components/schemas/CompelStage"
        events:
          type: array
          items:
            $ref: "#/components/schemas/CompelEvent"

    CompelEvent:
      type: object
      required: [type, created_at]
      properties:
        type:
          type: string
          enum: [STATE_CHANGED, EXTERNAL_TASK]
        status: { type: string }
        message: { type: [string, "null"] }
        created_at:
          type: string
          format: date-time
        task_id: { type: [integer, "null"], format: int64 }
        task_type: { type: [string, "null"] }
        trigger_state: { type: [string, "null"] }
        attempt_count: { type: [integer, "null"] }
        initiated_at: { type: [string, "null"], format: date-time }
        acknowledged_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    # --------- Renderings ---------

    RenderingListResponse:
      type: object
      required: [compel_id, renderings]
      properties:
        compel_id: { type: integer, format: int64 }
        renderings:
          type: array
          items:
            $ref: "#/components/schemas/Rendering"

    RenderingEnvelope:
      type: object
      required: [rendering]
      properties:
        rendering:
          $ref: "#/components/schemas/Rendering"

    Rendering:
      type: object
      required: [id, rendering_id, compel_id, status, mime_type, download_url]
      properties:
        id: { type: integer, format: int64 }
        rendering_id:
          type: integer
          format: int64
          description: Duplicates `id` for callers that prefer the explicit name.
        compel_id:
          type: [integer, "null"]
          format: int64
        status:
          type: string
          enum: [PENDING, RENDERING, COMPLETED, UNKNOWN]
        mime_type:
          type: string
          example: video/mp4
        size_bytes:
          type: [integer, "null"]
          format: int64
          description: Null until the file is available on disk.
        created_at:
          type: [string, "null"]
          format: date-time
        download_url:
          type: string
          description: |
            Relative URL. `GET` returns `409` until `status == COMPLETED` and the file
            is on disk.
          example: /api/v1/renderings/12/download

    # --------- Webhooks ---------

    WebhookCreateRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: HTTPS target URL. Must start with `https://`.
          example: https://integrator.example.com/webhooks/compeller
        events:
          description: |
            Subscribed event types. Accepts an array or comma-separated string.
            Use `*` (the default) to subscribe to every type this account publishes.
            Unknown values are dropped silently; if the filtered list is empty the
            subscription falls back to `*` so no caller gets a silent no-op.
          oneOf:
            - type: array
              items:
                type: string
                enum: ["*", "compel.completed", "compel.failed"]
            - type: string
              example: "compel.completed,compel.failed"

    WebhookCreateResponse:
      type: object
      required: [webhook]
      properties:
        webhook:
          $ref: "#/components/schemas/WebhookWithSecret"

    WebhookUpdateRequest:
      type: object
      description: |
        Partial update payload. At least one of `url`, `events`, `active` must
        be provided. Fields not present are left unchanged.
      minProperties: 1
      properties:
        url:
          type: string
          format: uri
          description: Replacement HTTPS URL. Runs the same safety checks as registration.
        events:
          description: |
            Replacement event subscription. Accepts an array or comma-separated
            string (same shape as `WebhookCreateRequest`). Unknown values are
            dropped; an all-dropped list falls back to `*`.
          oneOf:
            - type: array
              items:
                type: string
                enum: ["*", "compel.completed", "compel.failed"]
            - type: string
        active:
          type: boolean
          description: Toggle delivery on/off without losing the registration or secret.

    WebhookEnvelope:
      type: object
      required: [webhook]
      properties:
        webhook:
          $ref: "#/components/schemas/Webhook"

    WebhookList:
      type: object
      required: [webhooks]
      properties:
        webhooks:
          type: array
          items:
            $ref: "#/components/schemas/Webhook"

    WebhookTestResult:
      type: object
      description: |
        Outcome of a synchronous `POST /api/v1/webhooks/{id}/test` call. `delivered`
        is `true` only on a 2xx response; blocked URLs, DNS failures, connect
        errors, timeouts, and non-2xx all surface as `delivered: false` with a
        human-readable `error` string.
      required: [webhook_id, event_id, event_type, delivered, latency_ms]
      properties:
        webhook_id: { type: integer, format: int64 }
        event_id: { type: string, example: "evt_f3d2e7c…" }
        event_type: { type: string, enum: [webhook.test] }
        delivered: { type: boolean }
        response_status:
          type: [integer, "null"]
          description: HTTP status returned by the endpoint, if one was received.
        response_body_preview:
          type: [string, "null"]
          description: First 200 chars of the endpoint's response body, if any.
        latency_ms:
          type: integer
          description: Wall time from safety check to response parsed, in milliseconds.
        error:
          type: [string, "null"]
          description: |
            Populated when `delivered` is false. Examples:
            `"url host '169.254.169.254' resolves to a reserved or internal address"`,
            `"ConnectException: Connection refused"`, `"endpoint returned HTTP 500"`.

    Webhook:
      type: object
      required: [id, url, events, active, created_at]
      properties:
        id: { type: integer, format: int64 }
        url: { type: string, format: uri }
        events:
          type: array
          items:
            type: string
          example: ["*"]
        active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    WebhookWithSecret:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: |
                HMAC-SHA256 signing key for this endpoint. Returned exactly once on the
                create response — store it immediately. There is no rotate-secret
                endpoint in this cut.
              example: "4b3c2f1e0a9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b"

    WebhookEvent:
      type: object
      description: |
        Envelope POSTed to a registered webhook endpoint on each delivery. The raw
        JSON body is signed via `X-Compeller-Signature: sha256=<hmac>` keyed by the
        endpoint's `secret`.
      required: [event_type, event_id, occurred_at, api_version, data]
      properties:
        event_type:
          type: string
          enum: [compel.completed, compel.failed]
        event_id:
          type: string
          description: Stable per-event identifier; suitable as an idempotency key.
          example: evt_01HW5Q3ZP9R7Y0
        occurred_at:
          type: string
          format: date-time
        api_version:
          type: string
          enum: [v1]
        data:
          type: object
          required: [compel]
          properties:
            compel:
              $ref: "#/components/schemas/WebhookCompelPayload"

    WebhookCompelPayload:
      type: object
      description: |
        Subset of the compel GET shape sufficient for integrators to pull the
        rendered output. On `compel.completed` both `rendering_id` and
        `download_url` are populated; on `compel.failed` they are null.
      required: [id, status, stage]
      properties:
        id: { type: integer, format: int64 }
        title: { type: [string, "null"] }
        status:
          type: string
          enum: [COMPLETED, FAILED_FINAL, FAILED_RETRYING, CANCELLED, PROCESSING]
        stage: { type: [string, "null"] }
        rendering_id:
          type: [integer, "null"]
          format: int64
        download_url:
          type: [string, "null"]
          description: Relative URL; pairs with `GET /api/v1/renderings/{id}/download`.
          example: /api/v1/renderings/12/download
        created_at: { type: [string, "null"], format: date-time }

    # --------- Shared ---------

    Paging:
      type: object
      properties:
        total:
          type: integer
          format: int64
        limit:
          type: integer
        max:
          type: integer
          description: Present on legacy listings (media). Equivalent to `limit`.
        offset:
          type: integer
