openapi: 3.1.0
info:
  title: ChangeThisFile API
  summary: The conversion API for AI builders.
  description: |
    The ChangeThisFile API provides a single, predictable HTTP surface covering 1,000+ supported conversion routes across images, video, audio, documents, spreadsheets, ebooks, archives, and fonts.

    Built for AI builders, automation pipelines, and back-office workflows that need flat, predictable pricing and broad format coverage.

    ## Authentication

    All requests to `/v1/*` (except `/v1/keys/free`) require an API key passed as a bearer token:

    ```
    Authorization: Bearer ctf_sk_xxxxxxxxxxxx
    ```

    Request a free API key by calling `POST /v1/keys/free` with an email address and following the verification link. The free developer tier includes 25 conversions per month shared across REST and authenticated MCP, with a 25 MB file size cap.

    ## Synchronous vs asynchronous

    `POST /v1/convert` is synchronous up to the active plan's file-size limit and returns the converted file inline. Use `POST /v1/jobs` when you need asynchronous processing.

    `POST /v1/jobs` always queues a job and returns a `job_id` immediately, regardless of file size. Use this when you want predictable async behaviour.

    ## Rate limits

    Every response includes the standard rate-limit headers so you can throttle clients in real time:

    | Header | Description |
    |---|---|
    | `X-RateLimit-Limit` | Requests permitted in the current window |
    | `X-RateLimit-Remaining` | Requests remaining in the current window |
    | `X-RateLimit-Reset` | Unix timestamp when the window resets |

    Exceeding either the per-minute rate limit or your monthly conversion quota returns HTTP `429` with a structured error body.

    ## Errors

    Every error response uses the same envelope:

    ```json
    { "error": { "code": "quota_exceeded", "message": "Monthly conversion quota exceeded.", "details": { "limit": 25, "used": 25 } } }
    ```

    See the `Error` schema for the full list of error codes.
  version: "1.1.0"
  contact:
    name: ChangeThisFile Support
    email: support@changethisfile.com
    url: https://changethisfile.com/contact
  termsOfService: https://changethisfile.com/terms
  license:
    name: Commercial
    url: https://changethisfile.com/terms

servers:
  - url: https://changethisfile.com
    description: Production

tags:
  - name: Conversions
    description: Synchronous and asynchronous file conversions.
  - name: Jobs
    description: Async-only conversion jobs. Use these for long-running operations or when you want to poll instead of holding the request open.
  - name: Account
    description: API keys and usage reporting.
  - name: Billing
    description: Stripe Checkout and customer portal.

paths:
  /v1/convert:
    post:
      operationId: convertFile
      tags: [Conversions]
      summary: Convert a file
      description: |
        Synchronously convert a file from one format to another. The endpoint accepts a multipart upload and returns the converted file inline. A file above the active plan's size limit is rejected with `413` — it is never queued; use `POST /v1/jobs` for asynchronous processing.

        Pass the file in `file` and the desired output format in `target`. The `source` field is **optional** — the API auto-detects it from the filename extension and falls back to magic-byte sniffing for renamed uploads. Both `source` and `target` are case-insensitive (e.g. `png`, `jpg`, `pdf`). Use `GET /v1/formats` to list every supported route.

        Quality presets vary by route. For lossy image and video routes, `quality` accepts an integer from `1` to `100`. For audio routes, it maps to bitrate presets. Routes that ignore quality silently drop the value.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: "#/components/schemas/ConversionRequest"
            encoding:
              file:
                contentType: application/octet-stream
      responses:
        "200":
          description: Conversion succeeded. The body contains the converted file (binary stream) along with a JSON-friendly summary in headers.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
            X-Conversion-Time:
              schema:
                type: string
              description: End-to-end conversion duration in milliseconds (e.g. `412ms`).
            Content-Disposition:
              schema:
                type: string
              description: Suggested filename for the converted artefact.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                $ref: "#/components/schemas/ConversionResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          description: Conversion route is not supported.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: Conversion engine is temporarily unavailable. Safe to retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/jobs:
    post:
      operationId: createJob
      tags: [Jobs]
      summary: Queue an async conversion job
      description: |
        Always queues a conversion regardless of file size. Returns a `job_id` immediately so the caller can poll `GET /v1/jobs/{job_id}` for status.

        Use this endpoint when you need predictable async behaviour — for example, when running conversions inside a queue worker that cannot block on a long HTTP request.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: "#/components/schemas/ConversionRequest"
      responses:
        "202":
          description: Job accepted and queued.
          headers:
            Location:
              schema:
                type: string
              description: Absolute URL of the queued job (same as `status_url` in the body).
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          description: Conversion route is not supported.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/jobs/{job_id}:
    get:
      operationId: getJob
      tags: [Jobs]
      summary: Poll the status of a conversion job
      description: |
        Returns the current state of a queued, processing, completed, or failed conversion job. Once a job is `completed`, the `result` object includes a signed download URL valid for 24 hours.
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: job_id
          required: true
          schema:
            type: string
            format: uuid
          description: The job identifier returned by `POST /v1/jobs` or `POST /v1/convert` (when the conversion was queued).
      responses:
        "200":
          description: Current job status.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Job not found or has expired (jobs are retained for 7 days).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/keys/free:
    post:
      operationId: createFreeKey
      tags: [Account]
      security: []
      summary: Request a verified free API key
      description: |
        Sends an email-verification link. After verification, the dashboard creates the first API key and reveals it through a short-lived, account-bound reference. No credit card required.

        The free developer tier includes 25 conversions per month shared across REST and authenticated MCP, a 25 MB file size cap, and 10 requests per minute.

        Existing users receive the same sign-in link and can manage or rotate keys in the dashboard.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  description: Email address to associate with the key. Used for usage notifications.
                  examples:
                    - dev@example.com
      responses:
        "202":
          description: Verification email accepted.
          content:
            application/json:
              schema:
                type: object
                required: [ok, verification_required, monthly_limit]
                properties:
                  ok:
                    type: boolean
                  verification_required:
                    type: boolean
                  monthly_limit:
                    type: integer
                    description: Conversions permitted per calendar month on the issued plan.
                  message:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/usage:
    get:
      operationId: getUsage
      tags: [Account]
      summary: Get current usage and plan limits
      description: |
        Returns the caller's plan, current month conversion count, monthly quota, file size cap, and per-minute rate limit. Useful for surfacing usage inside customer-facing dashboards.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Current usage snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Usage"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/plans:
    get:
      operationId: listPlans
      tags: [Billing]
      security: []
      summary: List public self-serve plans
      description: |
        Returns the catalogue of self-serve plans (Free, Hobby, Startup, Scale, Growth) with their conversion quotas, file size caps, rate limits, and pricing. The Enterprise plan is not returned — custom contracts are negotiated via support.
      responses:
        "200":
          description: Plan catalogue.
          content:
            application/json:
              schema:
                type: object
                required: [plans]
                properties:
                  plans:
                    type: array
                    items:
                      $ref: "#/components/schemas/Plan"

  /v1/billing/checkout:
    post:
      operationId: createCheckoutSession
      tags: [Billing]
      summary: Start a Stripe Checkout session
      description: |
        Creates a Stripe Checkout session for upgrading to a paid plan. Returns a hosted URL the caller can redirect the user to.

        Both monthly and annual intervals are supported. Annual plans are billed once a year at a 20% discount versus the monthly rate.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan, interval]
              properties:
                plan:
                  $ref: "#/components/schemas/PlanId"
                interval:
                  type: string
                  enum: [monthly, annual]
      responses:
        "200":
          description: Stripe Checkout session created.
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    format: uri
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/billing/portal:
    post:
      operationId: openBillingPortal
      tags: [Billing]
      summary: Open the Stripe customer portal
      description: |
        Returns a hosted Stripe customer-portal URL for the authenticated user. The portal lets the customer change plan, update payment method, view invoices, and cancel.

        Requires the user to have completed at least one Checkout session (otherwise no Stripe customer exists yet).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Portal session created.
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    format: uri
        "400":
          description: User does not have a Stripe customer record yet.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/webhooks/stripe:
    post:
      operationId: stripeWebhook
      tags: [Billing]
      summary: Stripe webhook receiver (internal)
      description: |
        Internal endpoint that processes Stripe webhook events (`checkout.session.completed`, `invoice.paid`, `customer.subscription.deleted`). Not callable by API users — Stripe POSTs to this URL directly with a signed header.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw Stripe event payload.
      parameters:
        - in: header
          name: Stripe-Signature
          required: true
          schema:
            type: string
          description: HMAC-SHA-256 signature of the raw body, generated by Stripe with the configured webhook secret.
      responses:
        "200":
          description: Event processed (or duplicate ignored).
          content:
            application/json:
              schema:
                type: object
                properties:
                  received:
                    type: boolean
        "401":
          description: Invalid signature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: |
        Pass your API key as a bearer token. Get one for free at `POST /v1/keys/free` (no credit card required).

  headers:
    RateLimitLimit:
      schema:
        type: integer
      description: Number of requests permitted per minute on the caller's plan.
    RateLimitRemaining:
      schema:
        type: integer
      description: Number of requests remaining in the current window.
    RateLimitReset:
      schema:
        type: integer
        format: int64
      description: Unix timestamp (seconds) when the rate-limit window resets.

  responses:
    BadRequest:
      description: Request was malformed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PayloadTooLarge:
      description: File exceeded the plan's maximum size.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Per-minute rate limit or monthly conversion quota exceeded.
      headers:
        X-RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/RateLimitReset"
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    PlanId:
      type: string
      enum: [free, hobby, startup, scale, growth, enterprise]
      description: Self-serve plan identifier. `enterprise` is billed via custom contract — see `/pricing`.

    ConversionRequest:
      type: object
      required: [file, target]
      properties:
        file:
          type: string
          format: binary
          description: The file to convert. Maximum size depends on your plan (25 MB on Free, up to 5 GB on Growth).
        target:
          type: string
          description: Target format extension, case-insensitive (e.g. `jpg`, `pdf`, `mp4`). The only field most callers ever need.
          examples: [jpg]
        source:
          type: string
          description: |
            Source format extension. **Optional** — auto-detected from the filename extension and (if needed) magic bytes. Pass it explicitly only when you have a renamed file or need to force a specific interpretation.
          examples: [png]
        quality:
          type: integer
          minimum: 1
          maximum: 100
          description: Optional quality preset (1-100). Applies to lossy image, video, and audio routes. Ignored by lossless and document routes.

    ConversionResponse:
      type: object
      description: |
        Returned when the conversion succeeded inline. Most clients will read the binary body instead of this JSON envelope; the server sends JSON only when the request includes an `Accept: application/json` header.
      required: [file_url, size, duration_ms, source_format, target_format]
      properties:
        file_url:
          type: string
          format: uri
          description: Signed download URL valid for 24 hours.
        size:
          type: integer
          description: Size of the converted file in bytes.
        duration_ms:
          type: integer
          description: End-to-end conversion duration in milliseconds.
        source_format:
          type: string
        target_format:
          type: string

    Job:
      type: object
      required: [job_id, status_url, status]
      properties:
        job_id:
          type: string
          format: uuid
        status_url:
          type: string
          format: uri
          description: Absolute URL to poll for job status.
        status:
          type: string
          enum: [queued]

    JobStatus:
      type: object
      required: [job_id, status]
      properties:
        job_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [queued, processing, completed, failed]
          description: |
            * `queued` — accepted but not yet picked up by a worker
            * `processing` — currently being converted
            * `completed` — conversion finished; `result` contains the download URL
            * `failed` — conversion failed; `error` describes the cause
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        result:
          type: object
          description: Present when `status` is `completed`.
          properties:
            file_url:
              type: string
              format: uri
              description: Signed download URL valid for 24 hours.
            size:
              type: integer
              description: Size of the converted file in bytes.
            duration_ms:
              type: integer
            source_format:
              type: string
            target_format:
              type: string
        error:
          $ref: "#/components/schemas/Error"

    Usage:
      type: object
      required: [plan, conversions_this_month, monthly_limit, max_file_size, rate_per_minute]
      properties:
        plan:
          $ref: "#/components/schemas/PlanId"
        conversions_this_month:
          type: integer
          description: Conversions used in the current calendar month.
        monthly_limit:
          type: integer
          description: Conversions permitted per calendar month on the active plan. `-1` means unlimited. At quota, accounts hard-stop unless they have prepaid conversion credits.
        max_file_size:
          type: integer
          description: Maximum file size in bytes the active plan accepts.
        rate_per_minute:
          type: integer
          description: Maximum requests per minute the active plan accepts.
        credit_balance:
          type: integer
          description: Optional one-off credit balance (legacy a-la-carte packs only).

    Plan:
      type: object
      required: [id, name, monthly_price_cents, annual_price_cents, monthly_conversions, max_file_size, rate_per_minute, overage_cents_per_conversion]
      properties:
        id:
          $ref: "#/components/schemas/PlanId"
        name:
          type: string
        monthly_price_cents:
          type: [integer, "null"]
          description: Monthly price in USD cents. `0` for the free plan, `null` for enterprise.
        annual_price_cents:
          type: [integer, "null"]
          description: Annual price in USD cents. Reflects the 20% annual discount.
        monthly_conversions:
          type: [integer, "null"]
          description: Conversions permitted per calendar month. `null` for enterprise (custom).
        max_file_size:
          type: [integer, "null"]
          description: Maximum file size in bytes.
        rate_per_minute:
          type: [integer, "null"]
          description: Maximum requests per minute.
        overage_cents_per_conversion:
          type: [integer, "null"]
          description: Legacy pricing metadata. Runtime conversion above plan quota requires a prepaid conversion-credit balance; no automatic usage overage is charged.

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Machine-readable error code.
              enum:
                - bad_request
                - invalid_api_key
                - quota_exceeded
                - rate_limited
                - file_too_large
                - unsupported_route
                - conversion_failed
                - service_unavailable
                - not_found
                - invalid_signature
            message:
              type: string
              description: Human-readable error message.
            details:
              type: object
              description: Optional structured details (e.g. limit, used, retry_after).
