openapi: 3.0.3
info:
  title: Atomiser Partner API
  version: "1.0"
  description: |
    Embed Atomiser's AI media pipeline in your product, white-label, asynchronous, metered per tool.

    **All processing is async.** `POST /v1/jobs` returns `202` + a job id; poll `GET /v1/jobs/:id`
    or receive a signed webhook. Results are served as signed URLs (fresh 24h signature on every read).

    **Auth:** `Authorization: Bearer <key>`, `at_test_…` keys run the real pipeline against a test
    credit pool; `at_live_…` keys bill your monthly allocation. Keys are shown once at creation.

    **Billing:** jobs meter `units` (minutes / 1k chars) and charge whole credits, small jobs
    round up to per-tool minimums. Failed and cancelled jobs are never billed.

    **Typical durations:** most jobs finish in seconds to a few minutes — `repurpose`
    ~15–90s, `name_suggestions`/`show_description` ~10–60s, `tts` ~10–60s, `audio_enhance`/`convert`
    ~1–5min, `publish` <5s.
    Media tools scale with source length: `transcription` ~10–30s per source minute,
    `subtitles`/`clips`/`reframe`/`dubbing` several minutes per source minute. Any
    job still running past 2h is marked `failed` (webhook fires, never billed).
  contact:
    name: Atomiser Partnerships
    email: contact@atomiser.ai
  x-logo:
    url: https://atomiser.ai/icon.png
servers:
  - url: https://api.atomiser.ai
    description: Production (test and live keys both use this host)

security:
  - bearerAuth: []

tags:
  - name: Jobs
    description: Create, track, cancel, and retry async processing jobs
  - name: Catalogue
    description: Tools, rate card, and voices available to your key
  - name: Usage
    description: Metering and spend attribution

paths:
  /v1/jobs:
    post:
      tags: [Jobs]
      summary: Create a processing job
      description: |
        Dispatches work onto the Atomiser pipeline. Media tools take `input_url`
        (public or presigned S3/GCS/R2/CDN URL, YouTube/Vimeo/etc. and direct
        media files both work). `tts` takes `text`.

        Send `Idempotency-Key` to make retries safe, a repeated key returns the
        original job (`idempotent_replay: true`) within 24h.

        When `webhook_url` is provided, the response includes `webhook_secret`
        once, store it to verify `X-Signature` (HMAC-SHA256 of the raw body).
      operationId: createJob
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JobCreate'
            examples:
              transcription:
                summary: Transcribe a media URL
                value:
                  tool: transcription
                  input_url: https://cdn.example.com/episode-12.mp4
                  end_user_ref: user_4821
                  webhook_url: https://you.com/hooks/atomiser
              subtitles:
                summary: Burn subtitles onto a video
                value:
                  tool: subtitles
                  input_url: https://cdn.example.com/ep.mp4
                  options: { caption_style: mrbeast, language: en }
                  end_user_ref: user_4821
              tts:
                summary: Text-to-speech
                value:
                  tool: tts
                  text: Welcome back to the show.
                  options: { voice_id: CwhRBWXzGAHq8TQ4Fs17 }
      responses:
        '202':
          description: Job accepted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/JobEnvelope'
                  - type: object
                    properties:
                      webhook_secret:
                        type: string
                        description: Returned once when webhook_url is provided
                      idempotent_replay:
                        type: boolean
                        description: true when the Idempotency-Key matched an existing job
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [Jobs]
      summary: List recent jobs
      operationId: listJobs
      parameters:
        - name: tool
          in: query
          schema: { $ref: '#/components/schemas/Tool' }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/JobStatus' }
        - name: end_user_ref
          in: query
          schema: { type: string }
          description: Filter to one of your users' jobs
        - name: limit
          in: query
          schema: { type: integer, maximum: 200, default: 50 }
      responses:
        '200':
          description: Jobs, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs: { type: array, items: { $ref: '#/components/schemas/Job' } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/jobs/{jobId}:
    get:
      tags: [Jobs]
      summary: Get job status and result
      description: |
        Terminal states are `completed`, `failed`, `cancelled`. `result.url` is
        signed fresh on every read, each GET renews the 24h window.
      operationId: getJob
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: The job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/jobs/{jobId}/cancel:
    post:
      tags: [Jobs]
      summary: Cancel a queued or running job
      description: Never billed. Fires a `job.cancelled` webhook.
      operationId: cancelJob
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: The cancelled job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/jobs/{jobId}/retry:
    post:
      tags: [Jobs]
      summary: Retry a failed or cancelled job
      description: |
        Creates a **new** job with the original tool, inputs, options,
        end_user_ref, and webhook_url. Response includes `retried_from`.
      operationId: retryJob
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '202':
          description: The new job
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/JobEnvelope'
                  - type: object
                    properties:
                      retried_from: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/tools:
    get:
      tags: [Catalogue]
      summary: Your key's tool catalogue and rate card
      operationId: listTools
      responses:
        '200':
          description: "Tools with per-unit rates; `available: false` = exists but not scoped to this key"
          content:
            application/json:
              schema:
                type: object
                properties:
                  tools:
                    type: array
                    items: { $ref: '#/components/schemas/ToolInfo' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/voices:
    get:
      tags: [Catalogue]
      summary: Voices available to the tts tool
      operationId: listVoices
      responses:
        '200':
          description: Voice list
          content:
            application/json:
              schema:
                type: object
                properties:
                  voices:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        category: { type: string }
                        description: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/api-usage:
    get:
      tags: [Usage]
      summary: Usage and spend for a period
      description: |
        `end_user_ref` is echoed verbatim, reconcile it against your own
        user plans. `credits` fields report the actual ledger deduction.
      operationId: getUsage
      parameters:
        - name: period
          in: query
          schema: { type: string, pattern: '^\d{4}-\d{2}$', example: '2026-09' }
          description: Defaults to the current month
      responses:
        '200':
          description: Usage summary
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageReport' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/social/connect:
    post:
      tags: [Social]
      summary: OAuth connect URL for an end user's social account
      description: |
        Redirect your user to `connect_url`; after they authorize they land
        on your `redirect_url`. Then publish via the `publish` job tool.
      operationId: socialConnect
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [end_user_ref, platform, redirect_url]
              properties:
                end_user_ref: { type: string, description: "Your internal user ID — scopes the account to your user" }
                platform:
                  type: string
                  enum: [twitter, linkedin, instagram, facebook, tiktok, youtube, threads, pinterest]
                redirect_url: { type: string, format: uri, description: "Your page (https) the user lands on after authorizing" }
      responses:
        '200':
          description: Connect URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  connect_url: { type: string, format: uri }
                  platform: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/social/accounts:
    get:
      tags: [Social]
      summary: List an end user's connected social accounts
      description: Syncs from the publishing profile on every read — an OAuth flow that just completed is visible immediately.
      operationId: socialAccounts
      parameters:
        - name: end_user_ref
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Connected accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        platform: { type: string }
                        username: { type: string }
                        display_name: { type: string }
                        connected_at: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/social/posts/{id}:
    get:
      tags: [Social]
      summary: Publish status of a post created via the publish tool
      operationId: socialPostStatus
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Post status
          content:
            application/json:
              schema:
                type: object
                properties:
                  post:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
                      scheduled_for: { type: string, format: date-time }
                      published_at: { type: string, format: date-time }
                      end_user_ref: { type: string }
                      destinations:
                        type: array
                        items:
                          type: object
                          properties:
                            platform: { type: string }
                            status: { type: string }
                            post_url: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key (`at_test_…` or `at_live_…`), shown once at creation

  parameters:
    JobId:
      name: jobId
      in: path
      required: true
      schema: { type: string, example: job_9f8e7d2a1b }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }
      description: Unique per logical operation; repeated keys replay the original job for 24h

  schemas:
    Tool:
      type: string
      enum: [transcription, subtitles, clips, reframe, audio_enhance, convert, dubbing, tts, repurpose, name_suggestions, show_description, publish, thumbnails, audiogram]
    JobStatus:
      type: string
      enum: [queued, running, completed, failed, cancelled]
    RepurposeResult:
      type: object
      description: |
        `result.data` for `repurpose` jobs. Contract: every requested
        `content_type` yields at most one `assets[]` entry whose `type` echoes
        the requested value (lower_snake). Failed types are **absent** from
        `assets[]` and counted in `failed_count` — "2 of 3 generated" =
        `assets` has 2 entries, `failed_count` is 1.
      properties:
        title:
          type: string
          description: |
            Session label — `options.title` if you sent one, else derived from
            the source text. Metadata only: not an asset, not counted in
            `units`, never billed.
        assets:
          type: array
          items:
            type: object
            properties:
              type: { $ref: '#/components/schemas/ContentType' }
              content:
                type: string
                description: Generated text — markdown where the format calls
                  for it (threads/posts use line breaks + light markdown)
              word_count: { type: integer }
        failed_count:
          type: integer
          description: Requested types that failed to generate (unbilled)
    ContentType:
      type: string
      description: Valid `options.content_types` values for `repurpose`
      enum:
        - twitter_thread
        - linkedin_post
        - blog_post
        - show_notes
        - newsletter
        - instagram_caption
        - episode_summary
        - episode_title
        - key_takeaways
        - youtube_title
        - youtube_description
        - quotes
        - facebook_post
        - tiktok_script
        - pinterest_pin
        - sales_email
        - thread_hooks
    NameSuggestionsResult:
      type: object
      description: |
        `result.data` for `name_suggestions` jobs. `available` flags mean "no
        exact match found right now" — NOT a trademark/legal clearance, and
        NOT a reservation (platforms silently reserve some names/handles).
        Treat `null` as "check skipped or unavailable", never as "taken".
      properties:
        platform:
          type: string
          enum: [podcast, youtube, both]
        candidates:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              rationale: { type: string }
              suggested_handle:
                type: string
                description: Only present when platform is youtube or both
              available:
                type: object
                properties:
                  apple_podcasts: { type: boolean, nullable: true }
                  youtube_handle: { type: boolean, nullable: true }
    ShowDescriptionResult:
      type: object
      description: |
        `result.data` for `show_description` jobs. Evergreen show-level copy
        generated from a niche/topic brief — never episode-specific claims.
        `full` is directory-ready: hook first 1-2 sentences (the only part
        shown as the Apple Podcasts/Spotify search preview), then topic +
        themes, who it's for, cadence/credibility, a light follow/subscribe
        CTA. Podcast variants are plain text (no links/emojis — Apple strips
        or penalizes these); YouTube variants may include links/hashtags.
      properties:
        platform:
          type: string
          enum: [podcast, youtube, both]
        variants:
          type: array
          items:
            type: object
            properties:
              angle: { type: string, description: 'Short label, e.g. "benefit-led" or "topic-led"' }
              short: { type: string, description: 'Standalone preview/meta line, <=160 chars' }
              full: { type: string, description: 'Full description, plain text, 2-4 short paragraphs' }
              keywords:
                type: array
                items: { type: string }
    WebhookEvent:
      type: object
      description: |
        Body POSTed to `webhook_url` (and to registered webhook endpoints) on
        `job.completed`, `job.failed`, `job.cancelled`. Signed via `X-Signature`
        header = hex HMAC-SHA256 of the raw body with the job's webhook_secret.
      properties:
        event: { type: string, enum: [job.completed, job.failed, job.cancelled] }
        timestamp: { type: string, format: date-time }
        data:
          type: object
          properties:
            job: { $ref: '#/components/schemas/Job' }
      example:
        event: job.completed
        timestamp: "2025-10-01T09:32:14.522Z"
        data:
          job:
            id: job_9f8e7d2a1b
            tool: transcription
            status: completed
            units: 4.2
            unit: minute
            credits_charged: 5
            result:
              url: "https://…signed-url…"
              data: {}
    JobCreate:
      type: object
      required: [tool]
      properties:
        tool: { $ref: '#/components/schemas/Tool' }
        input_url:
          type: string
          format: uri
          description: |
            Required for media tools. Public URL or presigned URL (S3/GCS/R2/CDN).
            Direct media files (mp4/mp3/wav/mov/ogg/…) and YouTube/Vimeo/Rumble/
            X/TikTok URLs are supported. Must be publicly reachable, no auth headers.
        text:
          type: string
          description: Required for `tts`, the text to synthesize
        options:
          type: object
          additionalProperties: true
          description: |
            Per-tool options. Common keys: `language`, `title`, `caption_style`,
            `clip_count`, `aspect_ratio`, `voice_id` (tts, required), `preset`,
            `output_format` (convert, required), `target_language` (dubbing,
            required), `tone`, `duration_seconds` (skip probing).
            `content_types` (repurpose, required): array of `ContentType`
            values — unknown values are rejected with `invalid_options`.
            `platform` (name_suggestions/show_description, required): `podcast` |
            `youtube` | `both`. `count` (name_suggestions, optional, default 10,
            max 20; show_description, optional, default 2, max 3 — number of
            description variants). `keywords` (show_description, optional):
            array of seed keywords to weave in naturally.
        end_user_ref:
          type: string
          description: Your internal user ID for usage attribution (never an email)
        webhook_url:
          type: string
          format: uri
          description: |
            Per-job callback URL. The response returns `webhook_secret` — the
            HMAC signing secret for **this URL**: every job created with the same
            `webhook_url` gets the same secret back, so store it once per endpoint.
            The callback body is a `WebhookEvent` envelope (see schema); verify
            `X-Signature` = hex HMAC-SHA256 of the raw JSON body with this secret.
    Job:
      type: object
      properties:
        id: { type: string, example: job_9f8e7d2a1b }
        tool: { $ref: '#/components/schemas/Tool' }
        status: { $ref: '#/components/schemas/JobStatus' }
        input_url: { type: string }
        end_user_ref: { type: string }
        units: { type: number, description: "Billable quantity; meaning depends on tool" }
        unit: { type: string, description: "What units measure: minute | thousand_chars | asset | destination | job" }
        credits_charged: { type: number, description: Actual credits deducted }
        result:
          type: object
          description: |
            `result.data` shape per tool: `transcription` → {text, language,
            words[], segments[]}; `clips` → {clips: [{title,start,end,duration,
            score,reason,thumbnail_url}]}; `repurpose` → `RepurposeResult`
            (see schema); `name_suggestions` → `NameSuggestionsResult` (see
            schema); `show_description` → `ShowDescriptionResult` (see
            schema); `publish` → {post_id, status, scheduled_for,
            destinations}; other media tools → file under `result.url`.
          properties:
            url: { type: string, description: "Signed URL; fresh 24h signature per read" }
            data: { type: object, additionalProperties: true }
        error:
          type: object
          properties:
            message: { type: string, description: "Sanitized; never names upstream providers" }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        retried_from: { type: string }
    JobEnvelope:
      type: object
      properties:
        job: { $ref: '#/components/schemas/Job' }
    ToolInfo:
      type: object
      properties:
        tool: { $ref: '#/components/schemas/Tool' }
        unit: { type: string, enum: [minute, thousand_chars, job, asset, destination] }
        rate_credits: { type: number }
        input: { type: string, enum: [url, text] }
        available: { type: boolean }
    UsageReport:
      type: object
      properties:
        period: { type: string, example: '2026-09' }
        total_credits: { type: number }
        by_tool:
          type: object
          additionalProperties:
            type: object
            properties:
              units: { type: number }
              credits: { type: number }
              jobs: { type: integer }
        by_end_user:
          type: object
          additionalProperties:
            type: object
            properties:
              credits: { type: number }
              jobs: { type: integer }
        connected_accounts:
          type: object
          description: |
            Active social connections for your users (omitted when none).
            Drives the per-connected-account contract line where agreed.
          properties:
            total: { type: integer }
            by_end_user:
              type: object
              additionalProperties: { type: integer }
        events:
          type: array
          items:
            type: object
            properties:
              job_id: { type: string }
              tool: { $ref: '#/components/schemas/Tool' }
              units: { type: number }
              unit: { type: string }
              credits: { type: number }
              end_user_ref: { type: string }
              created_at: { type: string, format: date-time }
    Error:
      type: object
      properties:
        statusCode: { type: integer }
        error: { type: string, example: invalid_options }
        message: { type: string }

  responses:
    BadRequest:
      description: "Invalid input, `unknown_tool`, `invalid_input`, `invalid_options`, `duration_required`"
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, invalid, revoked, or expired API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: "`tool_not_allowed`, tool not in this key's scope"
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: "`job_not_found`, bad id or belongs to another org"
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: "`job_not_cancellable` / `job_not_retryable`"
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded, respect `Retry-After`
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
