This is the abridged developer documentation for moan.hot API # Introduction > What the moan.hot API does, who it is for, and how a request flows from photo to finished clip. The moan.hot API sells ready-made video, image and storyboard **effects** over plain HTTP. You send a photo and the slug of an effect, we run the tuned GPU pipeline behind it, and you get a finished file back by webhook or by polling. Every effect in the catalogue is already proven with paying users, so you ship a product instead of building a generation stack. ## Who it is for [Section titled “Who it is for”](#who-it-is-for) Founders and developers who want to sell AI video effects inside their own app, site or bot, under their own brand. You do not need GPUs, model tuning, ComfyUI workflows or a moderation pipeline. You need an HTTP client and a webhook endpoint. ## How it works [Section titled “How it works”](#how-it-works) 1. **Pick an effect.** `GET /effects` returns the catalogue with every variant (duration, resolution, audio, frame prep), its fixed price, typical time to result, and sell-ready assets: localised names, descriptions, posters and preview clips you can show in your own UI. 2. **Upload an image** with `POST /files`, or pass a public `https://` image URL directly. 3. **Create a generation** with `POST /generations`. The price is held on your prepaid balance and the job is queued. You get `202 Accepted` at once. 4. **Get the result.** We send `generation.succeeded` to your webhook with signed output URLs. The hold becomes a charge only when the output is stored. If anything fails on our side, the hold is released and you pay nothing. ## The basics [Section titled “The basics”](#the-basics) | | | | ---------- | -------------------------------------------------------------------------------- | | Base URL | `https://api.moan.hot/v1` | | Auth | `Authorization: Bearer mh_test_...` or `mh_live_...` | | Format | JSON in and out; uploads are `multipart/form-data` | | Money | Integer **micro-USD** in every `*_micros` field. `1000000` is $1.00 | | Time | RFC 3339 UTC strings | | Ids | Prefixed strings: `gen_`, `file_`, `eff_`, `evt_` | | Errors | RFC 9457 `application/problem+json` with a stable `code` | | Lists | Cursor pagination with `limit`, `starting_after`, `ending_before` and `has_more` | | Versioning | `/v1` only changes additively. Breaking changes go to `/v2` | ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * [Quickstart](/docs/quickstart/): a test key, a first generation and a webhook in about ten minutes. * [API reference](/docs/api/): every endpoint and schema, generated from the OpenAPI contract. * [AI assistants](/docs/ai-assistants/): hand these docs and the spec to your coding assistant. # AI assistants > Give your coding assistant the moan.hot API docs and OpenAPI spec through llms.txt, Markdown pages and openapi.json. These docs are built to be read by coding assistants as well as people. If you build with Claude, Cursor, Copilot, ChatGPT or similar, give the assistant the machine-readable versions below instead of pasting screenshots. ## llms.txt [Section titled “llms.txt”](#llmstxt) | File | What it is | | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- | | [`/docs/llms.txt`](https://api.moan.hot/docs/llms.txt) | A short index: what the API is, the basics, and links to every page | | [`/docs/llms-full.txt`](https://api.moan.hot/docs/llms-full.txt) | Every page in one Markdown file. Paste or attach this for full context | | [`/docs/llms-small.txt`](https://api.moan.hot/docs/llms-small.txt) | The same, trimmed of asides and extra whitespace, for smaller context windows | These follow the [llms.txt](https://llmstxt.org/) convention, so tools that look for `llms.txt` find them on their own. ## One page as Markdown [Section titled “One page as Markdown”](#one-page-as-markdown) Every page has a **Copy as Markdown** button next to its title, and a Markdown twin at the same path with `.md`, for example [`/docs/webhooks.md`](https://api.moan.hot/docs/webhooks.md). Use it to give an assistant exactly the page you are working on. ## The OpenAPI spec [Section titled “The OpenAPI spec”](#the-openapi-spec) The full contract is an OpenAPI 3.1 document at [`/docs/openapi.json`](https://api.moan.hot/docs/openapi.json). It has every endpoint, schema, webhook payload and, in `x-error-codes`, the error codes each operation can return. It is the same file the [API reference](/docs/api/) is built from. Most assistants do best with the spec plus one guide. A prompt that works well: ```text You are integrating the moan.hot API. The OpenAPI 3.1 spec is at https://api.moan.hot/docs/openapi.json and the guides are at https://api.moan.hot/docs/llms-full.txt. Read both before writing code. Write a server-side TypeScript module that: - lists video effects and caches them for an hour, - creates a generation with an Idempotency-Key and retries 429 and 5xx with backoff, same key, - verifies Standard Webhooks signatures on our /webhooks/moanhot route, - stores generation ids, not output URLs (they expire after 1 hour). Use fetch only, no SDK. Money fields are integer micro-USD. ``` If your assistant cannot fetch URLs, download the files and attach them: ```sh curl -O https://api.moan.hot/docs/openapi.json curl -O https://api.moan.hot/docs/llms-full.txt ``` You can also generate a typed client from the spec, for example with `npx openapi-typescript https://api.moan.hot/docs/openapi.json -o moanhot.d.ts`. ## Keep keys out of prompts [Section titled “Keep keys out of prompts”](#keep-keys-out-of-prompts) Never paste a live key into a chat. Assistants only need the docs and the spec; give your code the key through an environment variable such as `MOANHOT_API_KEY`. # Authentication > API keys, scopes, expiry, revocation, and the difference between test and live keys. Every request carries an API key as a bearer token: ```sh curl https://api.moan.hot/v1/account \ -H "Authorization: Bearer mh_live_4Hk2pQ9..." ``` Keys are server secrets. Never ship one in a mobile app, a browser bundle or a public repository. Your own backend calls the API on behalf of your users. ## Key format [Section titled “Key format”](#key-format) A key is `mh_live_` or `mh_test_` followed by 32 random characters. The first 12 characters (for example `mh_live_4Hk2`) are the **prefix**: the dashboard shows it, and every generation records the `api_key_prefix` that created it, so you can tell keys apart without seeing the secret. We store only a keyed hash of each key. The full key is shown **once**, when you create it. If you lose it, create a new one. ## Test and live [Section titled “Test and live”](#test-and-live) The prefix selects the mode, and the two modes never see each other’s data. | | `mh_test_` | `mh_live_` | | ------------------------------------- | ------------------------------------ | --------------------------------------------------- | | Charges | Never | The variant price, on success | | Outputs | Sample files on a simulated timeline | The real effect on your input | | Webhooks | Real, signed with the test secret | Real, signed with the live secret | | Works while your account is in review | Yes | Reads only; creates answer `account_pending_review` | See [Test mode](/docs/test-mode/) for how to force failures and timeouts. ## Scopes [Section titled “Scopes”](#scopes) A key carries one or more scopes. A call without the right scope answers `403 insufficient_scope`. | Scope | Allows | | ------------------- | ------------------------------------------------------------------- | | `generations:write` | `POST /files`, `POST /generations`, `POST /generations/{id}/cancel` | | `generations:read` | `GET` on generations, files, effects, prices and usage | | `account:read` | `GET /account`, `GET /usage` | Give each service the narrowest set it needs. A worker that only reads results needs `generations:read`. ## Expiry and rotation [Section titled “Expiry and rotation”](#expiry-and-rotation) A key can have an optional expiry date. After it passes, the key answers `401 api_key_expired`. To rotate without downtime: 1. Create the new key. 2. Deploy it everywhere the old one runs. 3. Revoke the old key once its “last used” time in the dashboard stops moving. ## Revocation [Section titled “Revocation”](#revocation) Revoking a key in the dashboard takes effect within seconds on every server. A revoked key answers `401 api_key_revoked`. Revocation cannot be undone. ## Disabling and deleting [Section titled “Disabling and deleting”](#disabling-and-deleting) Disabling a key in the dashboard stops it on the next request; calls with it answer `403 api_key_disabled` until you enable it again. Deleting a key revokes it first if it still works, then removes it from the dashboard list. A deleted key cannot be restored. You can also rename a key or change its scopes. The change applies to the next request; the mode and expiry of a key stay as created. A revoked or expired key can be deleted but not edited, and while the account is suspended an edit can only take access away. ## IP allowlist [Section titled “IP allowlist”](#ip-allowlist) Optionally, restrict a key to a list of IP addresses or ranges in the dashboard. Calls from anywhere else are refused with a 401. ## Errors [Section titled “Errors”](#errors) | Code | Status | Meaning | | ------------------------ | ------ | ------------------------------------------------------------- | | `invalid_api_key` | 401 | No `Authorization` header, a malformed key, or an unknown key | | `api_key_expired` | 401 | The key’s expiry date has passed | | `api_key_revoked` | 401 | The key was revoked | | `api_key_disabled` | 403 | The key is disabled in the dashboard | | `insufficient_scope` | 403 | The key lacks the scope this route needs | | `account_pending_review` | 403 | A live create while your account is still in review | | `account_suspended` | 403 | The account is suspended; only `GET /account` still answers | Full list on the [Errors](/docs/errors/) page. # Changelog > Changes to the moan.hot API v1. Additive only; breaking changes would ship as /v2. `/v1` changes only additively: new endpoints, new optional fields, new event types, new error codes on existing statuses. Write clients that ignore unknown fields and unknown event types. A breaking change would ship as `/v2`, announced well ahead, with `/v1` kept running. Subscribe to [`effect.published`](/docs/effects/#following-new-releases) for catalogue releases; they are not listed here. ## 2026-10-20: v1 draft [Section titled “2026-10-20: v1 draft”](#2026-10-20-v1-draft) * First public draft of the v1 contract: effects, prices, files, generations, account and usage. * Standard Webhooks signing for generation, effect, price and balance events. * RFC 9457 errors with a stable `code` registry. * Test mode with simulated timelines and forced outcomes. # Effects and catalogue > List effects, read their variants and prices, use their sell-ready assets, and follow new releases with effect.published. An **effect** is one finished, tuned generation recipe: a slug such as `golden-hour-pan`, the inputs it needs, the variants it can produce and a fixed price for each. The catalogue grows every week. ## List effects [Section titled “List effects”](#list-effects) ```sh curl "https://api.moan.hot/v1/effects?type=video&limit=20" \ -H "Authorization: Bearer $MOANHOT_API_KEY" ``` | Query | Meaning | | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | | `type` | `video`, `image` or `storyboard` | | `category` | One of the catalogue categories, as listed on each effect | | `orientation` | `straight` or `gay` | | `enabled` | `true` (default) returns only effects enabled for your account. `false` returns the whole catalogue | | `published_after` | Only effects published after this RFC 3339 time. Use it to sync new releases | | `limit`, `starting_after`, `ending_before` | Cursor pagination, 1 to 100 per page (default 20) | The response is a list object: ```json { "object": "list", "data": [ { "id": "eff_...", "slug": "...", "...": "..." } ], "has_more": true } ``` To get the next page, pass the last item’s `id` as `starting_after`. `GET /effects/{slug}` returns a single effect. An unknown or retired slug answers `404 effect_not_found`. ## Anatomy of an effect [Section titled “Anatomy of an effect”](#anatomy-of-an-effect) | Field | What it is | | ---------------------------- | -------------------------------------------------------------------------------------------- | | `slug` | The stable identifier you send to `POST /generations` | | `type` | `video`, `image` or `storyboard` | | `name`, `description` | Localised text: `en` always, plus `ru`, `de` and `es` where available | | `category`, `orientation` | Catalogue facets for your own filters | | `inputs[]` | Input slots: `name` (the key in `inputs`), `kind`, `required`, `min_short_side_px` | | `variants[]` | Every priced combination this effect offers (below) | | `blend` | `true` for two-person effects: two input photos, two people, one video | | `locations[]` | Storyboards only: `key`, localised `name`, `poster_url`, `preview_mp4_url`. Otherwise `null` | | `popularity_tier` | `top10`, `trending`, `new` or `null` | | `success_rate` | Share of generations that succeeded over the last 30 days | | `assets` | Sell-ready media (below) | | `enabled` | Whether your account can create it | | `published_at`, `retires_at` | Release date, and a retirement date when one is scheduled | ## Variants [Section titled “Variants”](#variants) A variant is one combination of axes with one fixed price. Video effects use `duration`, `resolution`, `audio` and `prep`; image effects use `resolution` only (`1mp` or `2mp`); storyboards use `location` and `resolution`. | Axis | Values | | ------------ | ------------------------------------------------------------------------------------------------- | | `duration` | `5s`, `10s`, `15s` for video | | `resolution` | `512p`, `640p`, `768p` for video; `1mp`, `2mp` for images; `432p`, `576p`, `720p` for storyboards | | `audio` | `true` adds a generated soundtrack | | `prep` | `true` runs frame preparation before animation, for inputs that need it | | `location` | Storyboards: the key of one of the effect’s `locations[]` | Each variant also has a `variant_key` (a stable name for the combination, `beach.720p` for a storyboard), `price_micros` and `typical_seconds`, the median time from request to result over the last 30 days. Send the axes, not the key, in `POST /generations`. A combination that is not listed answers `422 variant_unavailable`. ## Storyboards [Section titled “Storyboards”](#storyboards) A storyboard turns one photo into a short film: several scenes shot in one location, with the person from the photo in every scene, cut together into a single MP4. You choose the location and the resolution; each pair is its own variant with its own price. The locations are listed on the effect itself, because each storyboard has its own and new ones arrive with catalogue releases. Read them, with their posters and previews, from `GET /effects/{slug}`: ```sh curl "https://api.moan.hot/v1/effects/STORYBOARD_SLUG" \ -H "Authorization: Bearer $MOANHOT_API_KEY" ``` ```json { "slug": "STORYBOARD_SLUG", "type": "storyboard", "locations": [ { "key": "beach", "name": { "en": "Beach" }, "poster_url": "https://...", "preview_mp4_url": "https://..." }, { "key": "rooftop", "name": { "en": "Rooftop" }, "poster_url": "https://...", "preview_mp4_url": null } ], "variants": [ { "variant_key": "beach.432p", "location": "beach", "resolution": "432p", "price_micros": 1500000, "typical_seconds": 900 }, { "variant_key": "beach.720p", "location": "beach", "resolution": "720p", "price_micros": 2500000, "typical_seconds": 900 } ] } ``` To list every storyboard and its locations at once, call `GET /effects?type=storyboard`. A storyboard takes far longer than a video effect, often well over half an hour, so prefer a webhook to polling. See [Generations](/docs/generations/#storyboards) for the request. ## Assets you can show your users [Section titled “Assets you can show your users”](#assets-you-can-show-your-users) Every effect carries media ready for your own catalogue page: * `poster_url` and `poster_sfw_url`: still posters. Use the SFW poster on public or indexable pages. * `preview_mp4_url`, `preview_webp_url`: short looping previews. `preview_mp4_url` is always an MP4, or `null` when the preview exists only in another format; that preview is then in `renditions[]`. * `example_inputs[]`: sample input photos, `{position, url}` (position `2` is the second photo of a blend), so you can show input next to output. * `renditions[]`: the same preview at several sizes and formats (`mp4`, `webm`, `webp`, `jpg`), with `width` and `height`. Asset URLs are public, content-addressed and safe to cache forever. Hotlink them or copy them to your own CDN. ## Prices [Section titled “Prices”](#prices) `GET /prices` returns every effect and variant price in the current price-list version, plus the `next` version and its `effective_at` when a change is scheduled. See [Pricing and billing](/docs/pricing/). ## Enabling effects [Section titled “Enabling effects”](#enabling-effects) New effects can be enabled for your account automatically or after you review them, as you choose in the dashboard. A create for an effect you have not enabled answers `403 effect_disabled`. ## Following new releases [Section titled “Following new releases”](#following-new-releases) Subscribe a webhook endpoint to these events to keep your catalogue in sync without polling: | Event | When | | ------------------ | ---------------------------------------------------------------------------- | | `effect.published` | A new effect is live and enabled for your account. `data` is the full effect | | `effect.retired` | An effect is scheduled to stop. `data.retires_at` is at least 14 days ahead | After `retires_at`, creates for that slug answer `404 effect_not_found`, and generations still queued for it end `failed` with `effect_retired` (never charged). If you prefer polling, call `GET /effects?published_after=` once a day. # Errors > The RFC 9457 problem shape, which errors to retry, the order checks run in, and every error code the API returns. Every error is `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)) with three extra members: `code`, `request_id` and, when one field is to blame, `param`. ```json { "type": "https://api.moan.hot/docs/errors#input_rejected", "title": "Input rejected", "status": 422, "detail": "The input could not be processed. You were not charged.", "code": "input_rejected", "param": "inputs.image", "request_id": "req_2b7xk1v0p9q3s4" } ``` | Member | Meaning | | ------------ | ----------------------------------------------------------------------------------------------- | | `code` | The stable machine-readable code. **Branch on this.** Each code maps to exactly one HTTP status | | `type` | A link to this code’s entry on this page | | `title` | Fixed text per code | | `status` | The HTTP status, repeated | | `detail` | Human-readable text that may change. Show it in logs; never parse it | | `param` | The request field at fault, such as `variant.duration` | | `errors[]` | For `invalid_input`: one `{param, message}` per bad field | | `request_id` | Also in the `X-Request-Id` header. Quote it when you contact support | ## When to retry [Section titled “When to retry”](#when-to-retry) Retry only codes marked **Retryable** below: `rate_limited`, `queue_full`, `idempotency_in_progress`, `upstream_unavailable` and `internal_error`. * Back off exponentially: 1 s, 2 s, 4 s and so on, capped at 60 s, with random jitter. * When a `Retry-After` header is present, wait at least that long. * On `POST /generations`, **always retry with the same `Idempotency-Key`** and body, so a retry can never create a second generation. Everything else needs a change to the request, the key or the account before it can succeed. ## Order of checks on POST /generations [Section titled “Order of checks on POST /generations”](#order-of-checks-on-post-generations) When several problems apply at once, the first one in this order wins: 1. The key: `invalid_api_key`, then `api_key_revoked`, then `api_key_expired`, then `api_key_disabled`, then `insufficient_scope` 2. The account: `account_suspended`, or `account_pending_review` for live keys 3. `rate_limited` 4. `idempotency_key_required`, then the idempotency lookup (`idempotency_in_progress`, `idempotency_key_mismatch`, or a replay) 5. The body: `invalid_request`, then `invalid_input`, then `webhook_url_rejected` 6. The catalogue: `effect_not_found`, then `effect_disabled`, then `variant_unavailable` 7. The inputs: `file_not_found` and `image_*`, then `input_rejected` (or `upstream_unavailable` if checks cannot run) 8. `queue_full` 9. `insufficient_balance` 10. `202 Accepted` ## Generation failures are not HTTP errors [Section titled “Generation failures are not HTTP errors”](#generation-failures-are-not-http-errors) A generation that fails after `202` ends with `status: "failed"` and an `error.code` of `input_rejected`, `gpu_failure`, `timeout`, `upstream_unavailable`, `internal_error` or `effect_retired`. Its hold is released and you are not charged. See [Generations](/docs/generations/). ## Every code [Section titled “Every code”](#every-code) Any route can also answer `route_not_found` (unknown path or method) and `invalid_request` (malformed body). [`invalid_request`](#invalid_request)400Invalid request * When Body is not valid JSON / multipart, wrong `Content-Type`, unknown top-level field (`additionalProperties: false`), both `starting_after` and `ending_before` * Retry no * What to do Fix the request shape * Returned by `POST /v1/files``POST /v1/generations` [`route_not_found`](#route_not_found)404Route not found * When Unknown path or method under `/v1` * Retry no * What to do Check the URL and method against the reference [`invalid_api_key`](#invalid_api_key)401Invalid API key * When No `Authorization`, not `Bearer mh_(live|test)_…`, or the key hash is unknown * Retry no * What to do Send a valid key from the dashboard * Returned by `GET /v1/account``GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` [`api_key_expired`](#api_key_expired)401API key expired * When `expires_at` is in the past * Retry no * What to do Create a new key; rotate before expiry * Returned by `GET /v1/account``GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` [`api_key_revoked`](#api_key_revoked)401API key revoked * When `revoked_at` is set * Retry no * What to do Use another key * Returned by `GET /v1/account``GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` [`api_key_disabled`](#api_key_disabled)403API key disabled * When The key was disabled in the dashboard (`disabled_at` is set); reversible * Retry no * What to do Enable the key in the dashboard, or use another key * Returned by `GET /v1/account``GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` [`insufficient_scope`](#insufficient_scope)403Insufficient scope * When Key lacks the scope the route needs (`generations:write`, `generations:read`, `account:read`; effects/prices/files need `generations:read` or `:write`) * Retry no * What to do Create a key with the scope * Returned by `GET /v1/account``GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` [`account_pending_review`](#account_pending_review)403Account pending review * When **Live** key on a `pending` account calls `POST /generations`. Test keys work while pending * Retry no * What to do Wait for review; integrate in test mode meanwhile * Returned by `POST /v1/generations` [`account_suspended`](#account_suspended)403Account suspended * When Account is `suspended` or `rejected` (all routes except `GET /account`) * Retry no * What to do Contact support * Returned by `GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` [`insufficient_balance`](#insufficient_balance)402Insufficient balance * When Live create: `available_micros` < variant price * Retry no (until topped up) * What to do Top up, then retry with the **same** key and body * Returned by `POST /v1/generations` [`rate_limited`](#rate_limited)429Rate limitedRetryable * When Per-key requests-per-minute window exhausted * Retry yes, after `Retry-After` * What to do Back off; spread requests * Returned by `GET /v1/account``GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` [`queue_full`](#queue_full)429Queue fullRetryable * When Account has `max_concurrent` running and `queue_cap` queued already * Retry yes, after `Retry-After` * What to do Back off, or request a higher limit * Returned by `POST /v1/generations` [`effect_not_found`](#effect_not_found)404Effect not found * When Unknown slug, or not B2B-enabled, or retired past `retires_at` * Retry no * What to do Refresh the catalogue (`GET /effects`) * Returned by `GET /v1/effects/{slug}``POST /v1/generations` [`effect_disabled`](#effect_disabled)403Effect disabled * When Effect exists but the account disabled it (or auto-enable is off and it is unreviewed) * Retry no * What to do Enable it in the dashboard * Returned by `POST /v1/generations` [`variant_unavailable`](#variant_unavailable)422Variant unavailable * When The `variant` combination is not offered for this effect (`param: variant.duration` etc.) * Retry no * What to do Pick a variant listed in the effect's `variants` * Returned by `POST /v1/generations` [`invalid_input`](#invalid_input)422Invalid input * When Schema-valid JSON with a bad value: missing required slot, metadata over limits, bad cursor, bad date range; `errors[]` lists fields * Retry no * What to do Fix the fields named in `errors` * Returned by `GET /v1/effects``GET /v1/generations``POST /v1/generations``GET /v1/usage` [`file_not_found`](#file_not_found)404File not found * When `file_` id unknown, other account or mode, or past its 24 h TTL * Retry no * What to do Upload again * Returned by `GET /v1/files/{file_id}``POST /v1/generations` [`image_unreadable`](#image_unreadable)422Image unreadable * When Bytes do not decode as JPEG/PNG/WebP, zero-size, below min side, or the `image_url` fetch failed / was not an image * Retry no * What to do Send a valid image * Returned by `POST /v1/files``POST /v1/generations` [`image_too_large`](#image_too_large)413Image too large * When Over 20 MB or over 8192 px long side * Retry no * What to do Downscale * Returned by `POST /v1/files``POST /v1/generations` [`unsupported_media_type`](#unsupported_media_type)415Unsupported media type * When Image format other than JPEG/PNG/WebP (HEIC, GIF, ...) * Retry no * What to do Convert * Returned by `POST /v1/files``POST /v1/generations` [`input_rejected`](#input_rejected)422Input rejected * When The input could not be processed. No reason is exposed. **Never charged** (hold released) * Retry no * What to do Use a different input * Returned by `POST /v1/generations` [`webhook_url_rejected`](#webhook_url_rejected)422Webhook URL rejected * When `webhook_url` not https, too long, literal private IP, or one of our own addresses * Retry no * What to do Use a public https URL * Returned by `POST /v1/generations` [`idempotency_key_required`](#idempotency_key_required)400Idempotency key required * When `POST /generations` without `Idempotency-Key` * Retry no * What to do Mint a UUID per logical create * Returned by `POST /v1/generations` [`idempotency_key_mismatch`](#idempotency_key_mismatch)409Idempotency key reused * When Same account and mode+key within 24 h, different body hash * Retry no * What to do Use a new key for a different request * Returned by `POST /v1/generations` [`idempotency_in_progress`](#idempotency_in_progress)409Request in progressRetryable * When Same key while the first request is still executing * Retry yes, after \~1 s * What to do Retry with the same key; you will get the stored answer * Returned by `POST /v1/generations` [`generation_not_found`](#generation_not_found)404Generation not found * When Unknown id, or other account or mode * Retry no * What to do Check the id and key mode * Returned by `GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel` [`generation_not_cancelable`](#generation_not_cancelable)409Generation not cancelable * When Cancel on `succeeded` or `failed` * Retry no * What to do Nothing to do * Returned by `POST /v1/generations/{generation_id}/cancel` [`upstream_unavailable`](#upstream_unavailable)503Upstream unavailableRetryable * When Input screening, storage, or GPU control plane unreachable **before** anything was held * Retry yes * What to do Retry with backoff and the same key * Returned by `POST /v1/generations` [`internal_error`](#internal_error)500Internal errorRetryable * When Unhandled exception * Retry yes * What to do Retry with backoff and the same key; quote `request_id` if it persists * Returned by `GET /v1/account``GET /v1/effects``GET /v1/effects/{slug}``POST /v1/files``GET /v1/files/{file_id}``GET /v1/generations``POST /v1/generations``GET /v1/generations/{generation_id}``POST /v1/generations/{generation_id}/cancel``GET /v1/prices``GET /v1/usage` # Files > Upload input images with POST /files, or pass an image URL directly to a generation. Effects take images as input. There are two ways to provide one. ## Upload with POST /files [Section titled “Upload with POST /files”](#upload-with-post-files) ```sh curl https://api.moan.hot/v1/files \ -H "Authorization: Bearer $MOANHOT_API_KEY" \ -F "file=@photo.jpg" ``` ```json { "id": "file_3kq9c0m2x7z1b8v4n6p5r2t0", "object": "file", "bytes": 482113, "mime_type": "image/jpeg", "width": 1080, "height": 1440, "sha256": "9b1c...", "created_at": "2026-10-20T12:00:00Z", "expires_at": "2026-10-21T12:00:00Z" } ``` Use the `id` as the value of an input slot in `POST /generations`. A file expires **24 hours** after upload and can be used by any number of generations until then. `GET /files/{id}` returns its metadata. Upload when the image lives on your user’s device or behind authentication. The file is checked at upload, so a bad image fails fast, before you try to create anything. ## Pass a URL [Section titled “Pass a URL”](#pass-a-url) Instead of a `file_` id, put a public `https://` URL in `inputs`: ```json { "inputs": { "image": "https://cdn.example.com/u/123.jpg" } } ``` We fetch it once, at create. The URL must answer directly with the image bytes, without a login. ## Limits [Section titled “Limits”](#limits) | | | | ---------- | ------------------------------------------------------------------------------------------------------- | | Formats | JPEG, PNG, WebP | | Size | 20 MB at most | | Dimensions | 8192 px at most on the long side. Each effect sets a minimum short side in `inputs[].min_short_side_px` | | Lifetime | 24 hours for uploads | ## Errors [Section titled “Errors”](#errors) | Code | Status | When | | ------------------------ | ------ | ---------------------------------------------------------------------- | | `invalid_request` | 400 | The body is not `multipart/form-data` or has no `file` part | | `image_too_large` | 413 | Over 20 MB or over 8192 px on the long side | | `unsupported_media_type` | 415 | HEIC, GIF or another format. Convert to JPEG, PNG or WebP | | `image_unreadable` | 422 | The bytes do not decode, the image is too small, or a URL fetch failed | | `file_not_found` | 404 | Unknown id, another account’s or mode’s file, or past its 24 hours | For URL inputs, the `image_*` codes come from `POST /generations` instead. # 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 [Section titled “Create”](#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 [Section titled “An image effect”](#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 [Section titled “Storyboards”](#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 [Section titled “Idempotency”](#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 [Section titled “Status lifecycle”](#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 | ```plaintext 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 [Section titled “Read and list”](#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 [Section titled “Cancel”](#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 [Section titled “Outputs”](#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 [Section titled “Retention”](#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. # Pricing and billing > A fixed price per generation, held at create and charged only on success. Prepaid balance topped up with USDT. You pay a **fixed price per generation**, set per effect and variant. There is no subscription, no per-second metering and no charge for failures. ## Prices [Section titled “Prices”](#prices) Every variant in `GET /effects` carries its `price_micros`, and `GET /prices` returns the whole list at once: ```json { "object": "price_list", "version": 3, "effective_at": "2026-10-01T00:00:00Z", "currency": "USD", "next": null, "data": [ { "effect": "EFFECT_SLUG", "variant_key": "5s_640p", "price_micros": 100000 } ] } ``` Amounts are integer **micro-USD**: `100000` is $0.10 and `1000000` is $1.00. Use integers end to end; never convert through floating point. The price is fixed when you create a generation (`price_micros` and `price_list_version` on the generation), so a price change never affects work already queued. ### Price changes [Section titled “Price changes”](#price-changes) New prices are announced at least 7 days ahead. `GET /prices` shows the upcoming version in `next`, and a [`price.changed`](/docs/webhooks/#pricechanged) webhook lists every affected variant. ## Holds and charges [Section titled “Holds and charges”](#holds-and-charges) Your balance has two parts: `balance_micros` (money in the account) and `held_micros` (reserved for generations in flight). What you can spend is `available_micros = balance - held`. | Moment | What happens | | ------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Create | The price is **held**. If `available_micros` is lower than the price, the create answers `402 insufficient_balance` | | Success | The hold becomes a **charge** | | Failure on our side | The hold is **released**. You pay nothing | | `input_rejected` | Released. You pay nothing | | Cancel while `queued` | Released. You pay nothing | | Cancel while `processing` | Charged, because the GPU time was spent | `charged_micros` on each generation shows the final amount: 0 until charged, then the price. ## Top-ups [Section titled “Top-ups”](#top-ups) The balance is prepaid. Top up in the dashboard under **Billing** with **USDT**: you get an invoice with an address and amount, and the balance is credited **1:1 in USD** once the payment is confirmed on chain. A [`balance.topped_up`](/docs/webhooks/#balancelow-and-balancetopped_up) webhook fires when the credit lands. Set a low-balance threshold in the dashboard to get a `balance.low` webhook and an email before you run dry. ## Usage and reporting [Section titled “Usage and reporting”](#usage-and-reporting) `GET /usage?start=...&end=...` returns daily or hourly buckets of spend, counts of succeeded, failed and canceled generations, and p50 and p95 time to result. Group by `type`, `effect` or `api_key` to split costs across your own products or customers. The dashboard shows the same data with invoices and the full ledger. Test-mode generations never touch your balance. # Quickstart > Create a test key, run your first generation and receive its webhook. Complete copy-paste examples in curl, JavaScript and Python. This walk-through uses a **test key**. Test mode runs every validation, returns real (sample) output files on a realistic timeline, fires real signed webhooks and never charges you. Nothing here needs a balance. 1. ### Create a test key [Section titled “Create a test key”](#create-a-test-key) In the dashboard at [api.moan.hot/dashboard](https://api.moan.hot/dashboard), open **API keys** and create a key in **Test** mode with the `generations:write`, `generations:read` and `account:read` scopes. The key starts with `mh_test_` and is shown once. Store it as an environment variable: ```sh export MOANHOT_API_KEY="mh_test_..." ``` Check it works: ```sh curl https://api.moan.hot/v1/account \ -H "Authorization: Bearer $MOANHOT_API_KEY" ``` The response has `"mode": "test"` and your limits. 2. ### Pick an effect and a variant [Section titled “Pick an effect and a variant”](#pick-an-effect-and-a-variant) Every effect lists the variants it supports, with a fixed price for each. Take the first effect and its first variant: * curl ```sh curl "https://api.moan.hot/v1/effects?type=video&limit=1" \ -H "Authorization: Bearer $MOANHOT_API_KEY" ``` Note the `slug` and one entry from `variants`, for example: ```json { "variant_key": "5s_640p", "duration": "5s", "resolution": "640p", "audio": false, "prep": false, "price_micros": 100000, "typical_seconds": 95 } ``` * JavaScript ```js const API = 'https://api.moan.hot/v1'; const headers = { Authorization: `Bearer ${process.env.MOANHOT_API_KEY}` }; const res = await fetch(`${API}/effects?type=video&limit=1`, { headers }); const { data } = await res.json(); const effect = data[0]; const { duration, resolution, audio, prep } = effect.variants[0]; console.log(effect.slug, { duration, resolution, audio, prep }); ``` * Python ```python import os, requests API = "https://api.moan.hot/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOANHOT_API_KEY']}"} effects = requests.get(f"{API}/effects", params={"type": "video", "limit": 1}, headers=HEADERS).json() effect = effects["data"][0] v = effect["variants"][0] variant = {k: v[k] for k in ("duration", "resolution", "audio", "prep") if k in v} print(effect["slug"], variant) ``` 3. ### Upload an input image [Section titled “Upload an input image”](#upload-an-input-image) `POST /files` takes one JPEG, PNG or WebP image up to 20 MB and keeps it 24 hours. You can skip this step and pass a public `https://` image URL in the next one instead. * curl ```sh curl https://api.moan.hot/v1/files \ -H "Authorization: Bearer $MOANHOT_API_KEY" \ -F "file=@photo.jpg" ``` The response contains `"id": "file_..."`. * JavaScript ```js import { readFile } from 'node:fs/promises'; const form = new FormData(); form.append('file', new Blob([await readFile('photo.jpg')], { type: 'image/jpeg' }), 'photo.jpg'); const upload = await fetch(`${API}/files`, { method: 'POST', headers, body: form }); const file = await upload.json(); console.log(file.id); // file_... ``` * Python ```python with open("photo.jpg", "rb") as f: file = requests.post(f"{API}/files", headers=HEADERS, files={"file": ("photo.jpg", f, "image/jpeg")}).json() print(file["id"]) # file_... ``` 4. ### Create the generation [Section titled “Create the generation”](#create-the-generation) Send the effect slug, the variant, the input and a fresh `Idempotency-Key`. The key makes the call safe to retry: if the network drops, send the same request with the same key and you get the same generation back instead of a second one. * curl ```sh curl https://api.moan.hot/v1/generations \ -H "Authorization: Bearer $MOANHOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "effect": "EFFECT_SLUG", "variant": { "duration": "5s", "resolution": "640p", "audio": false, "prep": false }, "inputs": { "image": "file_..." }, "client_reference_id": "order-1042", "webhook_url": "https://example.com/webhooks/moanhot" }' ``` * JavaScript ```js const create = await fetch(`${API}/generations`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify({ effect: effect.slug, variant: { duration, resolution, audio, prep }, inputs: { image: file.id }, client_reference_id: 'order-1042', webhook_url: 'https://example.com/webhooks/moanhot', }), }); const generation = await create.json(); console.log(create.status, generation.id, generation.status); // 202 gen_... queued ``` * Python ```python import uuid generation = requests.post( f"{API}/generations", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "effect": effect["slug"], "variant": variant, "inputs": {"image": file["id"]}, "client_reference_id": "order-1042", "webhook_url": "https://example.com/webhooks/moanhot", }, ).json() print(generation["id"], generation["status"]) # gen_... queued ``` The answer is `202 Accepted` with the generation in `queued`. Every `inputs` slot the effect lists in `inputs[]` with `required: true` must be present; most video effects take one slot called `image`. 5. ### Receive the webhook [Section titled “Receive the webhook”](#receive-the-webhook) When the output is ready we `POST` a signed `generation.succeeded` event to your `webhook_url` (and to any endpoint registered in the dashboard). A minimal receiver that verifies the signature: * JavaScript ```js // npm install express import express from 'express'; import crypto from 'node:crypto'; const secret = Buffer.from(process.env.MOANHOT_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64'); const app = express(); app.post('/webhooks/moanhot', express.raw({ type: 'application/json' }), (req, res) => { const id = req.header('webhook-id'); const ts = req.header('webhook-timestamp'); const sigs = (req.header('webhook-signature') ?? '').split(' '); const expected = crypto.createHmac('sha256', secret).update(`${id}.${ts}.${req.body}`).digest('base64'); const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; const valid = sigs.some((s) => { const sig = s.split(',')[1] ?? ''; return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); }); if (!fresh || !valid) return res.sendStatus(400); const event = JSON.parse(req.body); if (event.type === 'generation.succeeded') { console.log(event.data.id, event.data.outputs[0].url); } res.sendStatus(200); }); app.listen(3000); ``` * Python ```python # pip install flask import base64, hashlib, hmac, json, os, time from flask import Flask, request SECRET = base64.b64decode(os.environ["MOANHOT_WEBHOOK_SECRET"].removeprefix("whsec_")) app = Flask(__name__) @app.post("/webhooks/moanhot") def moanhot_webhook(): body = request.get_data() msg_id = request.headers["webhook-id"] ts = request.headers["webhook-timestamp"] expected = base64.b64encode(hmac.new(SECRET, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode() sigs = [s.split(",", 1)[-1] for s in request.headers.get("webhook-signature", "").split()] if abs(time.time() - int(ts)) > 300 or not any(hmac.compare_digest(s, expected) for s in sigs): return "", 400 event = json.loads(body) if event["type"] == "generation.succeeded": print(event["data"]["id"], event["data"]["outputs"][0]["url"]) return "", 200 ``` The signing secret (`whsec_...`) for per-request `webhook_url` deliveries is on the dashboard’s **Webhooks** page, one per mode. To test locally, expose the port with a tunnel such as `cloudflared tunnel --url http://localhost:3000` and use its https URL. 6. ### Or poll [Section titled “Or poll”](#or-poll) Without a webhook, read the generation until it is terminal. Output URLs are signed, valid for one hour, and re-issued on every read. ```sh curl https://api.moan.hot/v1/generations/gen_... \ -H "Authorization: Bearer $MOANHOT_API_KEY" ``` Poll no more than every 5 seconds; `typical_seconds` on the variant tells you how long to expect. ## Go live [Section titled “Go live”](#go-live) 1. Wait for account review to finish. Test keys work while you wait; live creates answer `403 account_pending_review` until then. 2. [Top up your balance](/docs/pricing/) with USDT. 3. Create a `mh_live_` key and swap it in. Nothing else changes: same URLs, same payloads, same webhooks (signed with the live secret). # Rate limits and concurrency > Requests per minute per key, how many generations run at once, the queue cap, and the headers that tell you where you stand. Two separate limits apply: how fast you can **call** the API, and how many generations can **run** at once. ## Requests per minute [Section titled “Requests per minute”](#requests-per-minute) Each key has a sliding window of requests per minute, 60 by default. Every call counts once, reads included. Every `/v1` response tells you where you stand: ```http RateLimit-Policy: "default";q=60;w=60 RateLimit: "default";r=57;t=31 ``` `q` is the quota per window of `w` seconds; `r` is what remains and `t` the seconds until the window resets. These follow the IETF `RateLimit` header draft. Over the limit, the API answers `429 rate_limited` with a `Retry-After` header in seconds. Wait that long, then retry. If you need a higher limit, ask in the dashboard. ## Concurrency and the queue [Section titled “Concurrency and the queue”](#concurrency-and-the-queue) Your account runs up to `max_concurrent` generations at the same time. Creates beyond that are accepted and **queued**, first in, first out, up to `queue_cap` (50 by default). When both are full, creates answer `429 queue_full` with a `Retry-After` between 5 and 300 seconds, estimated from how fast your queue is draining. `GET /account` shows your limits and current load: ```json { "limits": { "requests_per_minute": 60, "max_concurrent": 4, "queue_cap": 50, "running": 4, "queued": 12 } } ``` ## Staying inside the limits [Section titled “Staying inside the limits”](#staying-inside-the-limits) * Prefer [webhooks](/docs/webhooks/) to polling. If you poll, read each generation no more than every 5 seconds. * Cache `GET /effects` and `GET /prices`; they change at most a few times a week, and webhooks tell you when. * Spread bursts. If your users can start many generations at once, queue them on your side and feed them in as `running` drops. * Retry `429` responses only after `Retry-After`, with the same `Idempotency-Key`. # Test mode > Integrate end to end with mh_test_ keys. Sample outputs, real webhooks, no charges, and switches to force failures. A `mh_test_` key runs the whole API against a simulated pipeline. Use it to build and test your integration, including while your account is still in review. ## What is real and what is simulated [Section titled “What is real and what is simulated”](#what-is-real-and-what-is-simulated) | | In test mode | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Validation | Real: auth, scopes, body, idempotency, catalogue, file checks, queue cap | | Timeline | Simulated: `queued` to `processing` within a few seconds, then `succeeded` about 20 seconds later. A storyboard runs one simulated job per stage, so it takes a few times longer | | Outputs | Sample clips and images for the variant, served from signed URLs like live outputs | | Webhooks | Real, signed with the test secret, retried on the same schedule | | Money | None. Nothing is held or charged, whatever your balance | | Data | Separate. Test keys never see live generations or files, and live keys never see test ones | ## Forcing outcomes [Section titled “Forcing outcomes”](#forcing-outcomes) Put `test_outcome` in `metadata` to exercise your error handling: | `metadata.test_outcome` | Result | | ----------------------- | ----------------------------------------------------------------------- | | `failed` | Ends `failed` with `error.code: "gpu_failure"` | | `timeout` | Ends `failed` with `error.code: "timeout"`, about a minute after create | | `slow` | Takes ten times longer than usual before succeeding | | `insufficient_balance` | The create answers `402 insufficient_balance` | ```sh curl https://api.moan.hot/v1/generations \ -H "Authorization: Bearer $MOANHOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "effect": "EFFECT_SLUG", "variant": { "duration": "5s", "resolution": "640p", "audio": false, "prep": false }, "inputs": { "image": "file_..." }, "metadata": { "test_outcome": "failed" } }' ``` The `metadata` key has no effect on live keys. [This sample image](/docs/test-mode/input-rejected.png) always ends in `input_rejected`, so you can test that path too. Upload it with `POST /files` (or pass its URL) and create a generation with it: the create succeeds, and the generation ends `failed` a few seconds later. ## Going live [Section titled “Going live”](#going-live) Switching is a key swap. Use the same code with a `mh_live_` key once your account is approved and your balance is topped up. Remember that live webhooks use the live signing secret. # Webhooks > Receive signed events for generations, the catalogue, prices and your balance. Verification in Node and Python, retries, event types and payloads. Webhooks tell your server when something happens, so you do not have to poll. They follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so any of its verifier libraries works out of the box. ## Two ways to receive events [Section titled “Two ways to receive events”](#two-ways-to-receive-events) * **Registered endpoints.** Add an https URL in the dashboard under **Webhooks**, pick the events it receives, and copy its signing secret. Each endpoint has its own secret and its own delivery log. * **Per-request `webhook_url`.** Pass `webhook_url` on `POST /generations` to get that generation’s `generation.*` events at a URL of your choice. These are signed with your account’s **ad-hoc secret**, one per mode, shown on the same dashboard page. Both can be used together; registered endpoints still receive their subscribed events. The `webhook_url` must be public `https`; anything else answers `422 webhook_url_rejected`. Webhook endpoints are managed only in the dashboard, so a leaked API key cannot redirect your events. ## What a delivery looks like [Section titled “What a delivery looks like”](#what-a-delivery-looks-like) ```http POST /webhooks/moanhot HTTP/1.1 Content-Type: application/json User-Agent: moanhot-api-webhooks/1 webhook-id: evt_2mY8c4Kq0x9b1v7n3p5r6t0 webhook-timestamp: 1792497731 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= { "type": "generation.succeeded", "timestamp": "2026-10-20T12:02:11Z", "mode": "live", "sequence": 3, "data": { "id": "gen_7c2x9q0k3m1z8b4v6n5p2r0t", "object": "generation", "status": "succeeded", "...": "..." } } ``` | Field | Meaning | | ----------- | ---------------------------------------------------------------------------- | | `type` | The event type, below | | `timestamp` | When the event happened (not when this attempt was sent) | | `mode` | `live` or `test` | | `sequence` | Per object, increases with every event. Use it to drop stale events | | `data` | The full current object: a Generation, an Effect, a price change or balances | ## Verify the signature [Section titled “Verify the signature”](#verify-the-signature) The signature is `v1,` plus the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of your `whsec_` secret. During a secret rotation the header carries two signatures separated by a space, one per secret, for 24 hours; accept either. Always: 1. Verify against the **raw** request body, before any JSON parsing. 2. Reject timestamps more than 5 minutes from your clock. 3. Compare in constant time. 4. Deduplicate on `webhook-id`; it is the same on every retry of one event. * Node ```js import crypto from 'node:crypto'; /** Returns the parsed event, or throws when the delivery is not authentic. */ export function verifyWebhook(rawBody, headers, secret) { const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = String(headers['webhook-signature'] ?? '').split(' '); if (!id || !timestamp) throw new Error('missing webhook headers'); if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('timestamp too old'); const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest(); const ok = signatures.some((entry) => { const [version, sig] = entry.split(','); if (version !== 'v1' || !sig) return false; const given = Buffer.from(sig, 'base64'); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); if (!ok) throw new Error('bad signature'); return JSON.parse(rawBody); } ``` Or use the official library: `npm install standardwebhooks`, then `new Webhook(secret).verify(rawBody, headers)`. * Python ```python import base64, hashlib, hmac, json, time def verify_webhook(raw_body: bytes, headers, secret: str) -> dict: """Returns the parsed event, or raises when the delivery is not authentic.""" key = base64.b64decode(secret.removeprefix("whsec_")) msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] if abs(time.time() - int(timestamp)) > 300: raise ValueError("timestamp too old") expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest() for entry in headers.get("webhook-signature", "").split(): version, _, sig = entry.partition(",") if version == "v1" and hmac.compare_digest(base64.b64decode(sig), expected): return json.loads(raw_body) raise ValueError("bad signature") ``` Or use the official library: `pip install standardwebhooks`, then `Webhook(secret).verify(raw_body, headers)`. ## Responding [Section titled “Responding”](#responding) Answer any `2xx` within 15 seconds to acknowledge. Do the real work after you respond, for example by putting the event on a queue. We do not follow redirects. Answering `410 Gone` disables the endpoint at once. ## Retries [Section titled “Retries”](#retries) A delivery that does not get a `2xx` is retried with exponential backoff, each delay with up to 10% jitter: | Attempt | Delay after the previous one | | ------- | ---------------------------- | | 1 | Immediately | | 2 | 5 seconds | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 5 hours | | 7 | 10 hours | | 8 | 14 hours | | 9 | 20 hours | | 10 | 20 hours | The last attempt comes about 3 days after the first. After that the delivery is marked failed. An endpoint with no successful delivery for 5 days in a row is disabled and you are emailed. Once you fix and re-enable it, you can replay failed deliveries from the dashboard’s delivery log, which also has **Resend** and **Send test event**. A test event has type `test.ping`, is signed like any other delivery, and never touches your balance. ## Ordering [Section titled “Ordering”](#ordering) Delivery is **at least once and unordered**. A retried `generation.queued` can arrive after `generation.succeeded`. Keep the highest `sequence` you have seen per object and ignore lower ones. The source of truth is always `GET /generations/{id}`. Output URLs in a late event may have expired; read the generation again for fresh ones. ## Event types [Section titled “Event types”](#event-types) | Type | When | `data` | | ---------------------- | -------------------------------------------------------- | ----------------------------------------- | | `generation.queued` | A create was accepted | Generation | | `generation.started` | A GPU worker started it | Generation | | `generation.succeeded` | Outputs are stored and the price charged | Generation, with fresh signed output URLs | | `generation.failed` | It failed and the hold was released | Generation, with `error` | | `generation.canceled` | A cancel completed | Generation | | `effect.published` | A new effect is live for your account | Effect | | `effect.retired` | An effect is scheduled to retire, at least 14 days ahead | Effect, with `retires_at` | | `price.changed` | A new price list is scheduled, at least 7 days ahead | `version`, `effective_at`, `changes[]` | | `balance.low` | Your available balance fell below your alert threshold | Balances and `threshold_micros` | | `balance.topped_up` | A top-up was credited | Balances and `topup` | ### price.changed [Section titled “price.changed”](#pricechanged) ```json { "type": "price.changed", "timestamp": "2026-10-20T09:00:00Z", "mode": "live", "data": { "version": 4, "effective_at": "2026-10-27T00:00:00Z", "changes": [ { "effect": "EFFECT_SLUG", "variant_key": "10s_640p", "old_price_micros": 180000, "new_price_micros": 160000 } ] } } ``` A `null` old price means a new variant; a `null` new price means the variant is going away. ### balance.low and balance.topped_up [Section titled “balance.low and balance.topped_up”](#balancelow-and-balancetopped_up) ```json { "type": "balance.topped_up", "timestamp": "2026-10-20T10:15:00Z", "mode": "live", "data": { "balance_micros": 250000000, "available_micros": 249600000, "threshold_micros": 20000000, "topup": { "invoice_id": "inv_...", "amount_micros": 200000000, "currency_paid": "USDTTRC20" } } } ``` `balance.low` fires once when the available balance crosses below the threshold, and re-arms when it rises back above. Set the threshold in the dashboard. Every payload schema is also in the [API reference](/docs/api/).