# Atomiser Partner API, Integration Guide

Everything here describes the **live** API at `https://api.atomiser.ai`.

---

## 1. Authentication

```
Authorization: Bearer at_live_xxxxxxxxxxxxxxxxxxxxxxxx
```

| Key prefix | Environment | Behavior |
|---|---|---|
| `at_test_` | Sandbox | Same pipeline & endpoints; draws on your test credit pool |
| `at_live_` | Production | Draws on your monthly API credit allocation |

- Keys are shown **once** at creation. Store in a secrets manager; never in client-side code.
- All calls are **server-to-server**, never expose keys to your end users' browsers/devices.
- Keys can be scoped to a subset of tools (`allowedTools`) and revoked/rotated at any time, ask your Atomiser contact.

## 2. The job model

All processing is asynchronous:

```
queued → running → completed
                 ↘ failed
       ↘ cancelled   (via POST /v1/jobs/:id/cancel)
```

Create a job → we dispatch it onto our processing pipeline → you poll `GET /v1/jobs/:id` or receive a signed webhook → fetch `result.url`. The signed URL is generated fresh on every `GET`, each read gives you a new 24h window, so there's no expiry trap.

You do **not** upload files to us, provide a publicly reachable `input_url`. Accepted:

- **Platform links**, YouTube, Vimeo, Rumble, X/Twitter, TikTok
- **Direct media files**, plain `https://…/episode.mp4` (or mp3/wav/mov/ogg/…) links on any public host or CDN
- **Private media**, presigned GET URLs (S3, GCS, R2, CloudFront, tokenized CDN). Generate one on your side; we never see your storage credentials.

Internal/private-network hosts are rejected for security. For tools where duration can't be probed from the URL, pass `options.duration_seconds`.

**Creation-time vs processing failures.** `POST /v1/jobs` only returns `200` + a job when the job is genuinely queued or running. Anything you can correct — invalid options, a tool not enabled on your key, an unprobeable duration, insufficient credits — comes back as an HTTP `4xx` at create time with an error code, never as a job that is already `failed`. A job that returns `failed` later (poll/webhook) is a real processing failure and is never billed.

## 3. Endpoints

### `POST /v1/jobs`

```json
{
  "tool": "subtitles",
  "input_url": "https://cdn.you.com/ep.mp4",
  "options": { "caption_style": "mrbeast", "language": "en" },
  "end_user_ref": "user_4821",
  "webhook_url": "https://you.com/hooks/atomiser"
}
```

→ `202 { job: { id, tool, status:"queued", created_at } }`

Headers: `Idempotency-Key: <string>` recommended (see §6).

### `GET /v1/jobs/:id`

```json
{ "job": {
    "id": "job_…", "tool": "subtitles", "status": "completed",
    "input_url": "…", "end_user_ref": "user_4821",
    "units": 38.2, "unit": "minute", "credits_charged": 45.8,
    "result": { "url": "https://signed-r2-url…", "data": { "format": "mp4" } },
    "created_at": "…", "completed_at": "…"
} }
```

Failed: `"status": "failed"`, `"error": { "message": "…" }`, sanitized, never names an upstream provider.

### `GET /v1/jobs?tool=&status=&end_user_ref=&limit=`

Recent jobs, newest first. `limit` ≤ 200. `end_user_ref` filters to one of your users' jobs — handy for per-customer dashboards. Poll-based reconciliation for your own dashboards.

`units` is the billable quantity; `unit` tells you what it measures (`minute` | `thousand_chars` | `asset` | `destination` | `job`) — no need to infer it from the tool.

### `POST /v1/jobs/:id/cancel`

Cancels a `queued` or `running` job. Returns the job with `"status": "cancelled"`. Jobs already in final processing stages (or terminal) return `409 job_not_cancellable`.

**Cancelled jobs are never billed**, no usage event is recorded. Processing already underway may still finish internally, but the job can never reach `completed`, so nothing is ever charged. Fires a `job.cancelled` webhook to subscribed endpoints.

### `POST /v1/jobs/:id/retry`

Retries a `failed` or `cancelled` job. Creates a **new** job reusing the original `tool`, `input_url`/`text`, `options`, `end_user_ref`, and `webhook_url`, the original job stays as your audit trail. Response includes `retried_from: <original job id>`. Other statuses return `409 job_not_retryable`.

### `GET /v1/tools`

Your key's rate card + availability. `available: false` → tool exists but not enabled for this key.

### `GET /v1/api-usage?period=YYYY-MM`

```json
{
  "period": "2026-09",
  "total_credits": 432.1,
  "by_tool":     { "transcription": { "units": 120.5, "credits": 120.5, "jobs": 9 } },
  "by_end_user": { "user_4821":     { "credits": 40.0, "jobs": 3 } },
  "connected_accounts": { "total": 12, "by_end_user": { "user_4821": 3 } },
  "events":      [ { "job_id": "…", "tool": "…", "units": 1.2, "unit": "minute", "credits": 1.2, "end_user_ref": "…", "created_at": "…" } ]
}
```

`end_user_ref` is echoed here verbatim, use it to attribute spend to your own users. We meter; **you** bill your users.

`connected_accounts` counts your users' active social connections (present only when any exist). If your agreement includes a per-connected-account monthly fee, this is the auditable number that drives it.

**Billing granularity:** credits settle in whole numbers (integer ledger), so small jobs round up to a per-tool minimum, e.g. a 6-second transcription meters `units: 0.101` but charges `1` credit; a short Pro-voice TTS job charges its 5-credit floor. `credits`/`credits_charged` always show the **actual** amount deducted, so reconcile against credits, not `units × rate`.

### `GET /v1/voices`

Voice list for the `tts` tool: `{ voices: [{ id, name, category, description }] }`.

## 4. Tools & options

| Tool | `input` | Required | Notable `options` |
|---|---|---|---|
| `transcription` | `input_url` | - | `language`, `title` |
| `subtitles` | `input_url` | - | `caption_style`, `language`, `title` |
| `clips` | `input_url` | - | `clip_count`, `aspect_ratio`, `title` |
| `reframe` | `input_url` | - | `aspect_ratio` (default `9:16`), `title` |
| `audio_enhance` | `input_url` | - | `preset` (`podcast`/`interview`/`voiceover`/`music`/`clean`/`audiobook`), `duration_seconds`, `settings` |
| `convert` | `input_url` | `options.output_format` | `input_format`, `preset`, `settings`, `duration_seconds` |
| `dubbing` | `input_url` | `options.target_language` | `source_language` (default `auto`), `lip_sync`, `remove_background`, `duration_seconds` |
| `tts` | `text` | `options.voice_id` | `speed` (0.5–2.0), `stability` (0–1), `title` |
| `repurpose` | `text` | `options.content_types` (array) | `tone`, `title` — see type list below |
| `publish` | `text` (post content) | `end_user_ref` | `scheduled_for` (ISO 8601 — omit for immediate), `account_ids` (default: all the end user's accounts), `media_url`, `platform_content` ({platform: text} overrides), `timezone` |

**Repurpose `content_types`:** `twitter_thread`, `linkedin_post`, `instagram_caption`, `blog_post`, `show_notes`, `episode_summary`, `episode_title`, `key_takeaways`, `newsletter`, `youtube_title`, `youtube_description`, `quotes`, `facebook_post`, `tiktok_script`, `pinterest_pin`, `sales_email`, `thread_hooks`. Unknown values are rejected with `invalid_options` (they never silently drop). Charged per generated asset — failed types are not billed.

**Repurpose `result.data` contract:**

```json
{ "title": "Episode title (options.title, else auto-derived — metadata, unbilled)",
  "assets": [ { "type": "show_notes", "content": "<markdown>", "word_count": 232 } ],
  "failed_count": 0 }
```

- Each requested `content_type` yields at most one `assets[]` entry, `type` echoes the requested value — key your UI off `assets[].type`.
- `content` is generated text (markdown where the format calls for it).
- Failed types are **absent** from `assets[]`, counted in `failed_count` — "2 of 3" = `assets` length 2, `failed_count` 1.
- `title` is session metadata (your `options.title` or auto-derived), not an asset and never billed.
- Note: `show_notes` content embeds its own "Suggested Episode Title" block — don't double-surface it if you also render `title`/`youtube_title`.

**Publishing flow:** social accounts belong to your end users, scoped by `end_user_ref`:

1. `POST /v1/social/connect` `{ end_user_ref, platform, redirect_url }` → `{ connect_url }` — send your user to this OAuth URL; after authorizing they land back on your `redirect_url`.
2. `GET /v1/social/accounts?end_user_ref=…` → their connected accounts (syncs live, so a fresh OAuth shows up immediately). Pass `id` values as `options.account_ids` to restrict destinations, or omit to post to all.
3. `POST /v1/jobs` `{ tool: "publish", text: "post content", end_user_ref, options: { scheduled_for? } }` → job completes when the post is accepted; `result.data.post_id` for status.
4. `GET /v1/social/posts/:id` → per-platform publish status + live `post_url`s.

Supported platforms: twitter, linkedin, instagram, facebook, tiktok, youtube, threads, pinterest. Billed per destination account.

**Results per tool:** `transcription` → `result.data` = `{text, language, words, segments}`. `subtitles`/`reframe`/`audio_enhance`/`convert`/`dubbing`/`tts` → `result.url` = media file. `clips` → `result.data.clips` = ranked moments `[{title,start,end,duration,score,reason,thumbnail_url}]`. `repurpose` → `result.data.assets` = `[{type, content, word_count}]` (only successful assets are billed). `publish` → `result.data` = `{post_id, status, scheduled_for, destinations}`; live per-platform status via `GET /v1/social/posts/:id`.

**Typical durations:** most jobs finish in seconds to a few minutes — `repurpose` ~15–90s, `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. A job still running past 2h is marked `failed` — webhook fires, never billed.

## 5. Webhooks

Pass `webhook_url` per job **or** ask us to register a persistent endpoint. We POST:

```
POST {your webhook_url}
Headers:  X-Signature: <hmac-sha256 hex>
          X-Event: job.completed | job.failed | job.cancelled
          X-Webhook-ID: <webhook id>
          User-Agent: Atomiser/1.0
```

**Body** — one envelope, always `data.job` = the full job object (same shape as `GET /v1/jobs/:id`, including `result`/`error`):

```json
{
  "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,
      "end_user_ref": "user_4821",
      "result": { "url": "https://…signed…", "data": { "text": "…" } }
    }
  }
}
```

**Verify:** `HMAC_SHA256(webhook_secret, rawBody)` === `X-Signature` (hex, constant-time compare). The `webhook_secret` is **per callback URL** — every job you create with the same `webhook_url` returns the same secret, so store it once per endpoint alongside your API key. Treat it like a password.

**Retries:** 5 attempts, exponential backoff, 10s timeout. Return `2xx` fast, do heavy work after acknowledging. Deliveries are **at-least-once**: dedupe on `job.id` + `event`.

## 6. Idempotency

Send `Idempotency-Key: <unique string>` on `POST /v1/jobs`. Repeating the same key within **24h** returns the original job (`idempotent_replay: true`), no duplicate processing, no double charge. Generate per logical operation (e.g. `youruser_4821_subtitles_ep12`), not per retry-attempt.

## 7. Rate limits & errors

Per-key request limit (default by tier; overridable per partner). Every response carries:

```
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 497
X-RateLimit-Reset: 1760000000
```

`429` includes `Retry-After` (seconds), back off and retry.

**Error shape:**

```json
{ "statusCode": 400, "error": "invalid_options", "message": "Tool 'dubbing' requires options.target_language (ISO 639-1 code)." }
```

| Code | HTTP | Meaning |
|---|---|---|
| `unknown_tool` / `tool_unavailable` | 400 | Bad tool name, or not exposed via API yet |
| `tool_not_allowed` | 403 | Tool not in your key's scope |
| `invalid_input` / `invalid_options` / `duration_required` | 400 | Request body issue |
| `job_not_found` | 404 | Bad id, or job belongs to another org |
| `job_not_cancellable` | 409 | Job already terminal or in final stage |
| `job_not_retryable` | 409 | Only `failed`/`cancelled` jobs can be retried |
| `rate_limit_exceeded` | 429 | Respect `Retry-After` |
| - | 401 | Missing/invalid/revoked key |

**Billing on failure:** a job that ends `failed` is never billed, the underlying pipeline refunds its own deduction and the API surcharge never applies.

## 8. Environments

One base URL; environment is the key. Use `at_test_` while integrating, its usage lands in the same `/v1/api-usage` view so you can audit your own test spend. Switch to `at_live_` when you go to production; nothing else changes.

## 9. Operational guidance

- **Poll politely:** for polling integrations, every 5–10s is plenty; heavy jobs take minutes. Webhooks are preferred at scale.
- **Store results:** `result.url` is signed for 24h, download and re-host on your side, or refetch the job for a fresh URL.
- **`end_user_ref` everywhere**, it's how you reconcile which of your users spent what.
- **Timeouts:** don't hold HTTP connections open waiting for jobs; the model is async by design.
- **File limits:** very large inputs slow the pipeline; pass `duration_seconds` on audio tools when probing isn't possible (private CDNs without range support).

## 10. Data retention & FAQ

**Retention.** `result.url` points to Atomiser-hosted output and is signed fresh on every poll (24h per signature), download and re-host anything you need long-term. Source media is retained on our side only as the backing processing artifact; to request deletion of a specific job's data (e.g. GDPR erasure), email `contact@atomiser.ai` with the `job_id` and we purge the underlying project and outputs.

**Your users' privacy.** `end_user_ref` is opaque to us, use an internal user ID, never an email or name.

### FAQ

**Can we test without spending?**
Your `at_test_` key runs on the same live pipeline against a separate test credit pool, onboarding includes a complimentary test allocation so you can integrate end-to-end before going live. Failed and cancelled jobs are never billed, in either environment.

**Our media is private (S3/GCS/behind auth). How do we submit it?**
Generate a presigned GET URL on your side (S3/GCS/R2/CloudFront all work) and pass it as `input_url`. We never need your storage credentials. Keep expiry ≥ a few minutes so the download doesn't race.

**What happens when our end user cancels mid-processing?**
Call `POST /v1/jobs/:id/cancel`. If the job hasn't reached its final stage it's marked `cancelled` and you're never billed. A `job.cancelled` webhook confirms it.

**A job failed, do we retry the same job?**
`POST /v1/jobs/:id/retry` creates a new job with identical inputs. The failed job is never billed. For transient fetch errors on your `input_url`, regenerate the presigned URL and create a fresh job instead.

**How fresh is the rate card?**
`GET /v1/tools` always returns the live per-tool rates enabled for your key, treat it as the source of truth; rates are contractual per partner and don't change silently.

**Where do we see spend?**
`GET /v1/api-usage`, per-tool totals plus `by_end_user` breakdown keyed on the `end_user_ref` you supply, so you can reconcile against your own plans.

**How do we handle refunds for our end users?**
Failed and cancelled jobs never bill you, the usage event is never written and any pipeline deduction is refunded to your org automatically. Refunding *your* users (e.g. reversing their plan usage) is your side: detect `job.failed`/`job.cancelled` via webhook and adjust their allowance in your system.

**Is there an OpenAPI spec or SDK?**
`openapi.yaml` ships in this repo and is also hosted at `atomiser.ai/partner-api.openapi.yaml`, import it into Postman/Insomnia, or generate a typed client with `openapi-generator`. A first-party SDK isn't needed yet; the API is 8 endpoints.

**Is there a status page / SLA?**
For incident history and uptime commitments, contact `contact@atomiser.ai`, enterprise agreements include SLA terms.
