# One More Shot API — reference for AI agents

Machine-readable reference for the One More Shot public API (beta).
Human version: https://www.onemoreshot.ai/developers/api/
OpenAPI: https://app.onemoreshot.ai/api/openapi.json
Last reconciled with the implementation: 2026-09-23.

One More Shot generates music-related videos (lyric videos, music videos) from audio.
The API provides lyric transcription, lyric videos and narrative music videos. Calls are charged in tokens
from the account's balance.

## Base URL

```
https://api.onemoreshot.ai/v1
```

## Authentication

- Every request needs an API key passed as a Bearer token:
  `Authorization: Bearer oms_live_...`
- Keys are created by a human in the One More Shot app under Settings → API.
  Key creation requires at least one purchase and API access enabled for the account. Beta availability may be limited.
- Keys are secrets. Never place a key in client-side code, source control, or logs.
- A revoked key returns `401` on every call.

## Conventions

- Request bodies and non-empty responses are JSON (`Content-Type: application/json`).
  Request bodies are limited to 1 MB. A confirmed repeated cancellation returns an empty 204.
- Generation endpoints are asynchronous: `POST` returns `202` with a job `id`;
  poll the matching `GET` endpoint until `status` is `completed`, `failed` or
  `canceled`. Poll every 15–30 seconds and back off on errors. Queue/provider
  delays vary; a client timeout does not cancel the job.
- A new job is admitted before media validation, initially with `credits_charged: 0`.
  Workers validate the media and balance, then charge before provider execution.
  Invalid media or insufficient balance fails the job asynchronously without a
  charge. Charged failures and confirmed cancellations are refunded; inspect
  `credits_refunded` alongside `credits_charged`.
- Rate limit: 60 authenticated requests per calendar minute per key by default,
  including reads and cancellations. On `429`, honor `Retry-After: 60`. The
  counter is shared across instances; unavailable limit storage returns `503`.
- Responses use `Cache-Control: private, no-store`. Keep response data private.

## Idempotency and retries

Send a unique `Idempotency-Key` on each new POST (1–128 printable non-space ASCII
characters). Reuse that same key and identical body after a timeout or retry.
The key is scoped to the account and operation and retained for 24 hours.
A replay returns the existing job with `202` and `Idempotency-Replayed: true`;
its current status/charge may differ from the initial response. Reusing a key
with a different request returns `409 idempotency_conflict`. Without a key,
each POST can create and charge a separate job. The optional `idempotency_key`
body field is also accepted; if both forms are supplied, they must match.

## Errors

Error responses always use this envelope:

```json
{ "error": { "code": "invalid_request", "message": "Human-readable detail." } }
```

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | A parameter is missing or invalid — the message explains which one. |
| 401 | `missing_api_key` | No Bearer API key in the Authorization header. |
| 401 | `invalid_api_key` | Invalid or revoked API key. |
| 403 | `insufficient_scope` | Key lacks the scope required by the endpoint. |
| 404 | `not_found` | Unknown resource or job owned by another account. |
| 409 | `idempotency_conflict` | The same idempotency key was used for different input. |
| 409 | `not_cancellable_yet` | Cancellation waiting period has not elapsed; honor Retry-After. |
| 409 | `not_cancellable` | This job cannot be canceled in its current state. |
| 429 | `rate_limit_exceeded` | Honor Retry-After: 60. |
| 503 | `api_unavailable` | New API jobs are temporarily disabled. |
| 503 | `rate_limit_unavailable` | Limits cannot be checked; retry later. |
| 500 | `internal_error` | Retry POST with the same idempotency key and body. |

After `202`, failures such as `insufficient_credits`, invalid media or provider
errors appear on the job's `error` object through GET and, if configured, the
signed terminal webhook. HTTP acceptance is not a guarantee of successful work.

## Pricing

These are default rates; configured rates can differ. Video cost is the measured
duration multiplied by its rate, rounded up to a whole token. A 180-second basic
video costs 5,940 tokens at the default rate.

| Feature | Model | Cost |
| --- | --- | --- |
| Lyric transcription | flat | 5 tokens per song |
| Lyric video (basic) | per second | 33 tokens per second of audio |
| Lyric video (pro) | per second | 40 tokens per second of audio |
| Narrative video (basic) | per second | 50 tokens per second of audio |
| Narrative video (pro) | per second | 80 tokens per second of audio |

---

## GET /account

Returns the account's token balance and this key's limits. Use it to verify
credentials and check available credits before dispatching work.

```
curl https://api.onemoreshot.ai/v1/account \
  -H "Authorization: Bearer oms_live_..."
```

Response `200`:

```json
{ "credits": 4200, "scopes": ["lyric_videos", "narrative_videos"], "rate_limit_rpm": 60, "webhook_secret": "3f1a…9c" }
```

`webhook_secret` is the HMAC key for verifying webhook signatures (see Webhooks).
It is `null` until the account's first job that includes a `webhook_url`; it is
available as soon as that initial `202` admission succeeds.

---

## POST /lyric-videos/transcriptions

Transcribes a song into time-coded lyric segments (the same segments that drive
lyric video generation). Cost: 5 tokens, refunded on failure.

Request body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `song_url` | string | yes | Public http(s) audio URL; up to 100 MB and 420 seconds. Trim bounds must fit within the original file. |
| `trim_start` | number | no | Start of the section to transcribe, in seconds. Requires `trim_end`. |
| `trim_end` | number | no | End of the section, in seconds. Must be greater than `trim_start`. |
| `webhook_url` | string | no | Public https URL notified on terminal outcomes (see Webhooks). |

```
curl -X POST https://api.onemoreshot.ai/v1/lyric-videos/transcriptions \
  -H "Authorization: Bearer oms_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: YOUR_UNIQUE_REQUEST_ID" \
  -d '{ "song_url": "https://your-cdn.com/song.mp3", "trim_start": 0, "trim_end": 60 }'
```

Response `202`:

```json
{ "id": "aBc123XyZ", "status": "processing", "credits_charged": 0 }
```

Transcription is queued. Duration varies with queue and provider load; poll the
GET endpoint below or consume the signed webhook.

---

## GET /lyric-videos/transcriptions/{id}

Returns the status and result of a transcription job.

Response `200` (completed):

```json
{
  "id": "aBc123XyZ",
  "status": "completed",
  "credits_charged": 5,
  "credits_refunded": 0,
  "language": "en",
  "segments": [
    {
      "text": "First line of the song",
      "start": 8.5,
      "end": 10.6,
      "duration": 2.1,
      "wordCount": 5,
      "timedWords": [{ "text": "First", "start": 8.5, "end": 8.9 }]
    }
  ],
  "error": null,
  "created_at": "2026-01-01T12:00:00.000Z",
  "completed_at": "2026-01-01T12:00:14.000Z"
}
```

Fields:

- `status` — `processing` | `completed` | `failed`
- `language` — detected language code (completed jobs only)
- `segments` — lyric lines with start/end seconds and per-word timings
- `error` — `{ code, message }` object or `null`; failure reason when `status` is `failed`; charged tokens are refunded

---

## POST /lyric-videos

Turns a song into a full lyric video. Optionally pass `segments` from the
transcription endpoint (edited or not) to control the displayed lyrics; without them
the song is transcribed automatically during generation.

Cost: priced per second of audio (see Pricing). The server probes the real audio
duration before charging, so the price always matches the file sent. Refunded
automatically if the generation fails.

Request body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `song_url` | string | yes | Public http(s) audio URL, up to 100 MB. Original duration must be 10–420 seconds. |
| `quality` | string | no | `basic` (720p) or `pro` (1080p). Default `basic`. |
| `aspect_ratio` | string | no | `9:16` (default), `16:9` or `1:1`. |
| `style_preset` | string | no | Visual style for the generated scenes. Must be one of the supported preset keys (see Style presets below); omit for the default look. |
| `song_genre` | string | no | Genre hint, at most 500 characters. |
| `singer_image_url` | string | no | Public http(s) photo URL; at most 10 MB and 25 megapixels. |
| `segments` | object[] | no | 1–1000 ordered, non-overlapping lyric segments within the audio duration. See segment rules below. |
| `webhook_url` | string | no | Public https URL notified on terminal outcomes (see Webhooks). |

Segment rules: each entry must include `text` (up to 10,000 characters), numeric
`start`, `end` and `duration`, a non-negative integer `wordCount`, and a
`timedWords` array (up to 1000 words). `duration` must match `end - start` within
0.05 seconds. Word times must fit inside their parent segment. Media-dependent
validation happens asynchronously after admission.

Style presets — `style_preset` accepts exactly one of these keys; any other value
is rejected with `400 invalid_request`:

| Key | Label |
| --- | --- |
| `80S_ILLUSTRATION` | 80s Illustration |
| `ART_BRUT` | Art Brut |
| `ART_POSTER` | Art Poster |
| `BAUHAUS` | Bauhaus |
| `BLUEPRINT` | Blueprint |
| `C4D_CARTOON` | C4D Cartoon |
| `CHILDRENS_BOOK` | Children's Book |
| `COLLAGE` | Collage |
| `DOODLE` | Doodle |
| `EMOTIONAL_MINIMAL` | Emotional Minimal |
| `FLAT_ART` | Flat Art |
| `FLAT_VECTOR` | Flat Vector |
| `GRAFFITI_I` | Graffiti |
| `GRAFFITI_II` | Graffiti II |
| `MINIMAL_ILLUSTRATION` | Minimal |
| `MIXED_MEDIA` | Mixed Media |
| `PAINT_GESTURE` | Paint Gesture |
| `POP_ART` | Pop Art |
| `RETRO_ETCHING` | Retro Etching |
| `WATERCOLOR` | Watercolor |
| `WEIRD` | Weird |
| `WOODBLOCK_PRINT` | Woodblock Print |

```
curl -X POST https://api.onemoreshot.ai/v1/lyric-videos \
  -H "Authorization: Bearer oms_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: YOUR_UNIQUE_REQUEST_ID" \
  -d '{ "song_url": "https://your-cdn.com/song.mp3", "quality": "basic", "aspect_ratio": "9:16" }'
```

Response `202`:

```json
{ "id": "dEf456UvW", "status": "processing", "credits_charged": 0 }
```

Rendering is queued and can take longer than a client timeout. Initial admission
has no measured duration; `duration_seconds` and the charge appear after successful
preparation. Poll every 15–30 seconds or consume the signed webhook. A client
timeout does not cancel work: retain the job ID instead of creating a new job.

---

## GET /lyric-videos/{id}

Returns the status and result of a lyric video job.

Response `200` (completed):

```json
{
  "id": "dEf456UvW",
  "status": "completed",
  "credits_charged": 5940,
  "credits_refunded": 0,
  "duration_seconds": 180,
  "video_url": "https://storage.googleapis.com/.../final.mp4",
  "thumbnail_url": "https://storage.googleapis.com/.../thumb.jpg",
  "error": null,
  "created_at": "2026-01-01T12:00:00.000Z",
  "completed_at": "2026-01-01T12:07:41.000Z"
}
```

Fields:

- `status` — `processing` | `completed` | `failed` | `canceled`
- `cancellation_requested` — true while a requested cancellation is awaiting confirmation
- `credits_refunded` — number of tokens returned after failure or cancellation
- `duration_seconds` — probed duration of the song, which the price was based on
- `video_url` / `thumbnail_url` — final assets (completed jobs only)
- `error` — `{ code, message }` object or `null`; failure reason when `status` is `failed`; charged tokens are refunded

---

## DELETE /lyric-videos/{id}

Queued videos can be canceled immediately, without charge, before a worker first
claims them for media preparation. Cancellation and that claim are checked
atomically: if cancellation wins, the job cannot start or charge.

Once media preparation has started, including retries and jobs waiting for the
next generation task, cancellation requires the account's configured waiting
period to have elapsed since job creation. The default fallback is 60 minutes; this
is independent of the displayed generation ETA. An early request returns
`409 not_cancellable_yet` with `Retry-After` in seconds.

```sh
curl -X DELETE https://api.onemoreshot.ai/v1/lyric-videos/dEf456UvW \
  -H "Authorization: Bearer oms_live_..."
```

An eligible cancellation is queued, returning `202`:

```json
{ "id": "dEf456UvW", "status": "processing", "cancellation_requested": true }
```

This is a request, not confirmation. Poll GET or wait for `job.canceled` to confirm
the final `canceled` state and full refund of charged tokens. Completion can win
that race. A repeated DELETE on a confirmed canceled job returns `204` with no
body; other terminal states return `409 not_cancellable`. There is no public
transcription cancellation endpoint.

---

## POST /music-videos

Create a narrative music video in one request. Existing video API keys also have
narrative access. The global API availability switch applies to this endpoint.

Required inputs: `song_url`, `prompt` (1–10,000 characters), and `characters`
(1–4 entries, exactly one with `role: "singer"`, the others `role: "character"`).
Every character requires `name` (1–100 characters) and `image_url`; an optional
`description` can contain 1–2,000 characters. Text cannot be blank. Duets are not supported.

Optional: `quality` (`basic`, default, or `pro`), `aspect_ratio` (`9:16`, default,
`16:9`, or `1:1`), `style_preset`, `webhook_url`, and `idempotency_key`.
Narrative styles: `90s-anime`, `ghibli`, `yellow-family`, `pixar-3d`, `ps2-low-poly`, `disney-90s`, `lego`, `cartoon-network`, `pixel-art`. Omit the style for realistic styling.
Unknown fields are rejected; do not send creative seeds, preview assets, song
metadata, provider settings or internal character IDs. Creative assets are
produced inside the pipeline without a separate preview/approval call.

```json
{
  "song_url": "https://example.com/song.mp3",
  "prompt": "Two friends reunite in a neon-lit city.",
  "quality": "basic",
  "aspect_ratio": "9:16",
  "style_preset": "90s-anime",
  "characters": [
    {
      "name": "Alex",
      "role": "singer",
      "image_url": "https://example.com/alex.jpg"
    },
    {
      "name": "Sam",
      "role": "character",
      "image_url": "https://example.com/sam.jpg",
      "description": "Alex’s childhood friend"
    }
  ],
  "webhook_url": "https://example.com/webhooks/oms"
}
```

Send this JSON to `https://api.onemoreshot.ai/v1/music-videos` with Bearer
authentication, `Content-Type: application/json`, and an `Idempotency-Key`.
The response is the same `202` job envelope as lyric video creation, initially
with `credits_charged: 0`. Workers validate and snapshot all media before charging.
Audio must be 10–420 seconds and at most 100 MB; every character image must be
at most 10 MB and 25 megapixels. Default prices are **50 tokens/second basic**
and **80 tokens/second pro**, rounded up to whole tokens after multiplying by
measured duration.
Basic delivers 720p; pro delivers 1080p. The selected renderer configuration is
saved on the job so retries do not change it.

## GET /music-videos/{id}

Poll `https://api.onemoreshot.ai/v1/music-videos/{id}` with the same key.
The response uses the same video job fields as lyric videos: status, charged and
refunded tokens, duration, timestamps, error, and `video_url`/`thumbnail_url`
on completion. Webhooks use `type: "narrative_video"` and the same signature,
delivery ID and terminal events. Job IDs are scoped to their owner and video type.

## DELETE /music-videos/{id}

Cancellation is available immediately before the first media-preparation claim.
After preparation starts, the configured cancellation delay from job creation
applies (60-minute fallback). An accepted request returns `202`; poll or await
`job.canceled` for confirmation. Confirmed repeated cancellation returns `204`.
Failures and confirmed cancellations refund charged tokens once. These endpoints
share the existing rate limit, queues and idempotency rules with the lyrics API.

---

## Webhooks

Instead of polling, pass a `webhook_url` (https required) in the body of
`POST /lyric-videos` or `POST /lyric-videos/transcriptions`. When the job reaches a
terminal state, One More Shot POSTs its terminal status and result to that URL, including charges/refunds,
error and timestamps, plus a `type` field
(`lyric_video` | `lyric_transcription`).

Headers on every delivery:

| Header | Value | Meaning |
| --- | --- | --- |
| `X-OMS-Event` | `job.completed` \| `job.failed` \| `job.canceled` | What happened to the job. |
| `X-OMS-Delivery` | job id | Deduplication key for receivers. |
| `X-OMS-Signature` | `sha256=<hex>` | HMAC-SHA256 of the raw request body, keyed with `webhook_secret`. |

Example delivery (lyric video, `X-OMS-Event: job.completed`):

```json
{
  "id": "dEf456UvW",
  "type": "lyric_video",
  "status": "completed",
  "credits_charged": 5940,
  "credits_refunded": 0,
  "duration_seconds": 180,
  "video_url": "https://storage.googleapis.com/.../final.mp4",
  "thumbnail_url": "https://storage.googleapis.com/.../thumb.jpg",
  "error": null,
  "created_at": "2026-01-01T12:00:00.000Z",
  "completed_at": "2026-01-01T12:07:41.000Z"
}
```

Signature verification: fetch `webhook_secret` from `GET /account` (created
automatically with your first webhook job), compute HMAC-SHA256 over the **raw
request body**, and compare with `X-OMS-Signature` using a constant-time comparison:

```js
const crypto = require('crypto');
const express = require('express');
const app = express();

// Verify against the RAW request body — the exact bytes that were signed.
app.post('/oms-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.OMS_WEBHOOK_SECRET) // from GET /account
    .update(req.body)
    .digest('hex');
  const signature = req.headers['x-oms-signature'] || '';

  const actualBytes = Buffer.from(signature, 'utf8');
  const expectedBytes = Buffer.from(expected, 'utf8');
  const valid = actualBytes.length === expectedBytes.length &&
    crypto.timingSafeEqual(actualBytes, expectedBytes);
  if (!valid) return res.status(401).end();

  const job = JSON.parse(req.body);
  // Persist/deduplicate req.headers['x-oms-delivery'] (equals job.id) before
  // applying side effects. Acknowledge duplicates with 2xx too.
  res.status(200).end();
});
```

Delivery semantics:

- Respond with any `2xx` within 10 seconds.
- Normally up to three total HTTP attempts: the first plus two retries with
  backoff. Webhooks are best-effort; keep polling available as a fallback.
- Retries and crash recovery can produce duplicate deliveries. Deduplicate
  persistently using `X-OMS-Delivery`; do not assume exactly-once delivery.

---

## Support

Beta API — surface may evolve. Questions, higher rate limits or volume pricing:
in-app support chat or https://1-more-shot.crisp.help/en/
