# Generations

Create a generation safely with an Idempotency-Key, follow its status, cancel it, and fetch outputs before they expire.

A **generation** is one run of one effect on your inputs. You create it, it moves through a short status lifecycle, and it ends with output files or an error.

## Create

```sh
curl https://api.moan.hot/v1/generations \
  -H "Authorization: Bearer $MOANHOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c1b8e-6a43-4c8e-9d61-2a1f7d1f9b20" \
  -d '{
    "effect": "EFFECT_SLUG",
    "variant": { "duration": "5s", "resolution": "640p", "audio": false, "prep": false },
    "inputs": { "image": "file_3kq9c0m2x7z1b8v4n6p5r2t0" },
    "metadata": { "user_id": "u_829" },
    "client_reference_id": "order-1042",
    "webhook_url": "https://example.com/webhooks/moanhot"
  }'
```

| Field | Required | Meaning |
|---|---|---|
| `effect` | yes | The effect slug |
| `variant` | yes | The axes of one listed variant: `duration`, `resolution`, `audio`, `prep`, and `location` for a storyboard |
| `inputs` | yes | One entry per input slot. The value is a `file_` id or a public `https://` image URL |
| `metadata` | no | Up to 20 string pairs, 500 characters each, returned on every read and webhook |
| `client_reference_id` | no | Your own id, up to 128 characters. Filter by it in `GET /generations` |
| `webhook_url` | no | An extra https destination for this generation's events. See [Webhooks](/docs/webhooks/) |

Unknown fields are rejected with `400 invalid_request`. The answer is `202 Accepted`, a `Location` header and the generation in `queued`:

```json
{
  "id": "gen_7c2x9q0k3m1z8b4v6n5p2r0t",
  "object": "generation",
  "status": "queued",
  "mode": "live",
  "effect": "EFFECT_SLUG",
  "type": "video",
  "variant": { "duration": "5s", "resolution": "640p", "audio": false, "prep": false },
  "variant_key": "5s.640p.noaudio.noprep",
  "price_micros": 100000,
  "charged_micros": 0,
  "price_list_version": 3,
  "metadata": { "user_id": "u_829" },
  "client_reference_id": "order-1042",
  "outputs": [],
  "error": null,
  "api_key_prefix": "mh_live_4Hk2",
  "created_at": "2026-10-20T12:00:00Z",
  "started_at": null,
  "finished_at": null,
  "expires_at": null
}
```

At create we validate the request, check every input image, and **hold** the variant price on your balance. If your available balance is too low the call answers `402 insufficient_balance` and nothing is created.

### An image effect

An image effect (`type: "image"`) takes one photo and returns one image. Its variants differ only by `resolution`: `1mp` is the standard output, `2mp` the upscaled one, each with its own price. Leave `duration`, `audio` and `prep` out; sending one of them answers `422 variant_unavailable`.

```sh
curl https://api.moan.hot/v1/generations \
  -H "Authorization: Bearer $MOANHOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0b7c2f4e-1d9a-4e57-8c3b-5f6a7e8d9c01" \
  -d '{
    "effect": "IMAGE_EFFECT_SLUG",
    "variant": { "resolution": "2mp" },
    "inputs": { "image": "file_3kq9c0m2x7z1b8v4n6p5r2t0" }
  }'
```

The hold, capture, webhooks and retention are the same as for video. A `succeeded` image generation has one output of `kind: "image"`.

### Storyboards

A storyboard takes one photo and a location. Send the location's key and a resolution as the variant:

```sh
curl https://api.moan.hot/v1/generations \
  -H "Authorization: Bearer $MOANHOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0b5e2c1a-3f7d-4a8e-b2c9-71d4e6f8a903" \
  -d '{
    "effect": "STORYBOARD_SLUG",
    "variant": { "location": "beach", "resolution": "720p" },
    "inputs": { "image": "file_3kq9c0m2x7z1b8v4n6p5r2t0" }
  }'
```

The price of that location and resolution is held at create, like any other generation, and charged once when the film is delivered. A storyboard runs far longer than a video effect, often well over half an hour (see the variant's `typical_seconds`); you get `generation.started` when work begins and `generation.succeeded` with one `video` output, the finished film. If the photo, or any frame we make from it, cannot be used, the generation ends `failed` with `input_rejected` and nothing is charged. The available locations are on the effect: see [Storyboards](/docs/effects/#storyboards).

## Idempotency

`POST /generations` requires an `Idempotency-Key` header: 1 to 255 printable ASCII characters. Use a fresh UUID for each logical create and reuse it for every retry of that create.

- **Same key, same body:** you get the first response again, with `Idempotent-Replayed: true`. No second generation, no second hold.
- **Same key, different body:** `409 idempotency_key_mismatch`.
- **Same key while the first request is still running:** `409 idempotency_in_progress`. Retry after a second.
- Keys are remembered for 24 hours per account and mode, across all your keys.
- Errors that may change on retry (`401`, `402`, `403`, `429`, `5xx`) are not stored, so retrying them with the same key runs fresh. After a top-up, retry an `insufficient_balance` create with the same key and body.

Missing the header answers `400 idempotency_key_required`.

## Status lifecycle

| Status | Meaning | Money |
|---|---|---|
| `queued` | Accepted, waiting for a GPU worker | Price held |
| `processing` | A worker started the job | Price held |
| `succeeded` | Outputs are stored and ready | Price charged |
| `failed` | Ended with `error`. Never your fault, never charged | Hold released |
| `canceled` | You canceled it | Released if it had not started; charged if it had |

```
queued ──> processing ──> succeeded
   │            ├──────> failed
   │            └──────> canceled (charged)
   └──────────────────> canceled (free)
```

`succeeded`, `failed` and `canceled` are terminal. `queue_seconds` and `execution_seconds` tell you where the time went.

When a generation fails, `error.code` is one of:

| `error.code` | Meaning |
|---|---|
| `input_rejected` | The input could not be processed. No reason is given |
| `gpu_failure` | The GPU job failed (storyboards first retry a brief GPU failure up to twice) |
| `timeout` | The job did not finish in time |
| `upstream_unavailable` | Storage or the GPU control plane failed mid-run |
| `internal_error` | Something broke on our side |
| `effect_retired` | The effect retired before the job could start |

You are never charged for a failed generation.

## Read and list

```sh
curl https://api.moan.hot/v1/generations/gen_7c2x9q0k3m1z8b4v6n5p2r0t \
  -H "Authorization: Bearer $MOANHOT_API_KEY"

curl "https://api.moan.hot/v1/generations?status=succeeded&client_reference_id=order-1042" \
  -H "Authorization: Bearer $MOANHOT_API_KEY"
```

`GET /generations` lists newest first. Filters (`status`, `effect`, `client_reference_id`, `created_gte`, `created_lt`) combine with AND, and pagination uses `limit`, `starting_after`, `ending_before` and `has_more`.

## Cancel

```sh
curl -X POST https://api.moan.hot/v1/generations/gen_7c2x9q0k3m1z8b4v6n5p2r0t/cancel \
  -H "Authorization: Bearer $MOANHOT_API_KEY"
```

- A `queued` generation is canceled and its hold released. You pay nothing.
- A `processing` generation is stopped as soon as possible. The GPU time is already spent, so the price is charged.
- Canceling an already canceled generation answers `200` with it.
- Canceling a `succeeded` or `failed` generation answers `409 generation_not_cancelable`.

## Outputs

A `succeeded` generation has `outputs[]`:

```json
{ "kind": "video", "url": "https://media.api.moan.hot/...", "expires_at": "2026-10-20T13:02:11Z", "mime_type": "video/mp4", "width": 640, "height": 960, "duration_seconds": 5.0, "bytes": 2483112 }
```

`kind` is `video`, `image` or `thumbnail`. Output URLs are **signed and valid for one hour**. Every `GET` and every webhook carries freshly signed URLs, so do not store the URL: store the generation id and read it again when you need the file, or copy the file to your own storage.

## Retention

| What | Kept for |
|---|---|
| Uploaded files (`POST /files`) | 24 hours from upload |
| Inputs used by a generation | 7 days after it finishes |
| Outputs | 30 days after it finishes |
| Generation records | Kept; `outputs` becomes `[]` once files are deleted |

The generation's `expires_at` tells you when its outputs will be deleted. Copy anything you need to keep.
