{"openapi":"3.1.0","info":{"title":"Novoads API","version":"2.19.0","description":"Generate video and static ads from your own code.\n\n### How it works\n\n1. **Estimate.** Ask what a call will cost. Spends nothing.\n2. **Create.** Submit the job. A `jobId` comes back immediately.\n3. **Wait.** Poll until the status is terminal. Typically 4 to 7 minutes for video.\n4. **Download.** Fetch the finished file from a URL signed at request time.\n\n### Quickstart\n\nCreate a key in the dashboard under **Settings → Developer**, then:\n\n```bash\nexport NOVOADS_API_KEY=\"novo_…\"\nexport NOVOADS_API=\"https://api.novoads.ai/v1\"\n\n# 1. Price it. Spends nothing.\ncurl -s \"$NOVOADS_API/estimates\" \\\n  -H \"Authorization: Bearer $NOVOADS_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"kind\": \"video\", \"durationSeconds\": 5,\n       \"prompt\": \"A woman in her kitchen holds up the bottle and smiles.\"}'\n\n# 2. Submit. A jobId comes back immediately; the video is not ready yet.\nJOB=$(curl -s \"$NOVOADS_API/videos\" \\\n  -H \"Authorization: Bearer $NOVOADS_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"model\": \"seedance-2.0\", \"aspectRatio\": \"9:16\",\n       \"durationSeconds\": 5,\n       \"prompt\": \"A woman in her kitchen holds up the bottle and smiles.\"}' \\\n  | jq -r .jobId)\n\n# 3. Poll until TERMINAL, not until succeeded. Typically 4 to 7 minutes.\nwhile :; do\n  STATUS=$(curl -s \"$NOVOADS_API/generations/$JOB\" \\\n    -H \"Authorization: Bearer $NOVOADS_API_KEY\" | jq -r .status)\n  case \"$STATUS\" in\n    succeeded) break ;;\n    failed|blocked|canceled) echo \"ended: $STATUS\"; exit 1 ;;\n  esac\n  sleep 10\ndone\n\n# 4. Download. The URL is signed at request time, so it is never stale.\ncurl -L -o ad.mp4 \"$NOVOADS_API/generations/$JOB/watch\" \\\n  -H \"Authorization: Bearer $NOVOADS_API_KEY\"\n```\n\nThe same flow with every caveat written out is the Full flow (bash) sample on `POST /videos`. To put your own product in the ad, upload it first with `POST /uploads` and pass the returned `assetId` as `startImageAssetId`.\n\n**If a call times out**, the work may still have run and been charged — the response carrying the `jobId` is the thing that was lost, and `GET /generations` is the recovery path.\n\n### Authentication\n\nKeys are minted in **Settings → Developer**. Every endpoint takes `Authorization: Bearer novo_…`; the key is shown once, at creation.\n\n### Credits\n\n<details>\n<summary>How credits are charged</summary>\n\nGeneration spends credits from your organization's balance. Amounts in this API are **credits**, the unit shown on your billing page. `POST /estimates` quotes a call for free and runs the same validation the real call will, so a bad prompt costs a round trip rather than a render.\n\nAPI generations draw from your plan's credits at the same rate as the dashboard. There is no separate API wallet and no free API tier — one balance, one price, whichever surface spends it.\n\nAccess requires a live Novoads subscription — any of them, the `$1` trial included. The API is not a separate tier and is not sold separately: a subscription that can generate in the dashboard can generate here, at the same price, from the same balance.\n\n</details>\n\n### Rate limits\n\n<details>\n<summary>The ceilings, and how to read a 429</summary>\n\n60 requests per minute per key. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; a `429` also carries `Retry-After`.\n\nA per-organization limit of 180 requests per minute applies across every key the organization holds, so minting more keys does not raise the total. A `429` from it names the organization limit in its message; the `X-RateLimit-*` headers keep describing your per-KEY budget, which may still show room.\n\nGeneration is additionally capped at 5 jobs in flight per organization. Submitting past that returns `429` with `error.details.reason` = `concurrency_limit` and the current count; wait for a job to finish and retry. Captions are capped separately at 10 with their own `caption_concurrency_limit` reason, and transcripts separately again at 10 with `transcript_concurrency_limit` — three queues, so a batch of either derived operation never blocks a render. Reads and uploads are not affected.\n\nA second ceiling of 1200 requests per minute applies per client address across ALL keys, including requests that fail to authenticate. It exists to stop runaway loops, sits well above the per-key limit, and a normal integration will never reach it. It reports `Retry-After` but not the `X-RateLimit-*` headers — those describe your per-key budget, which a `429` from this ceiling has not touched.\n\n</details>\n\n### Errors\n\n<details>\n<summary>The error envelope</summary>\n\nEvery failure returns the same envelope: `{ \"error\": { \"code\", \"message\", \"requestId\" } }`. Branch on `code` — the message wording is not part of the contract. `requestId` matches the `x-request-id` response header; quoting it in a support request is enough to find the whole server-side trace.\n\nTwo codes never appear on a documented call and so are not listed per-operation. A request to a real path with a method it does not implement returns `405 method_not_allowed` with an `Allow` header naming the methods that work; a request to a path that does not exist returns `404 not_found`.\n\n</details>\n\n### Asynchrony\n\n<details>\n<summary>Submit and poll, and the inline exception</summary>\n\nVideo generation is submit-and-poll: `POST /videos` returns a `jobId`, and `GET /generations/{jobId}` reports where it is. Image generation currently returns the finished images inline, but uses the same job envelope, so moving it behind the queue later will not change the shape of the response.\n\n</details>\n\n### MCP\n\nAd analysis — reading an existing ad into a structured hook, beats, casting and layout breakdown — lives on the [MCP connector](https://novoads.ai/mcp) as `analyze_ad`, and on deployments that enable it, at `POST /analyses` in this document. One operation at one price: the same breakdown, the same flat fee, the same per-organization ceilings. Reach for MCP if your agent already speaks it and for the REST path if you are driving this API from your own code. If `/analyses` is not in the reference below, this deployment has not turned it on and the connector is the way in.\n\nIf you drive this API from an agent, our Claude Code skill pack is open source at [novoads/claude-code-ads](https://github.com/novoads/claude-code-ads): working prompt rules, guardrails and scripts that call these endpoints, rather than a second copy of the reference below.","contact":{"name":"Novoads support","url":"https://docs.novoads.ai","email":"hola@novoads.ai"}},"servers":[{"url":"https://api.novoads.ai/v1","description":"Production"}],"security":[{"ApiKeyAuth":[]}],"tags":[{"name":"Videos","description":"Generate a video."},{"name":"Images","description":"Generate a static ad image."},{"name":"Captions","description":"Burn subtitles into a video you generated here. The text comes from its audio; you choose the style."},{"name":"Music","description":"Generate a music bed from a prompt. One request, two takes, one charge."},{"name":"Voice","description":"Narrate a line of text, and list the voices that can say it. The only endpoint here that returns the finished audio rather than a job."},{"name":"Transcripts","description":"The words of a video, with their timings. Returned inline, in seconds."},{"name":"Uploads","description":"Hand us a real product photo, logo or source ad."},{"name":"Products","description":"Organize your work. Create a product, then file generations under it."},{"name":"Jobs","description":"Follow anything you submitted to completion: list your jobs, check one, download the result."},{"name":"Reference","description":"What the API can do and what it charges, both derived from the configuration the generation paths enforce. Nothing here spends credits or creates a job."},{"name":"Competitor ads","description":"Find the ads a brand is running right now, from Meta's public Ad Library. Spends a flat fee per search and returns links you have to download promptly."},{"name":"Analysis","description":"Read an ad you have uploaded into a structured breakdown: where the hook ends and why, the beats, the on-screen text, the casting, and which layers of the frame were generated. Spends a flat fee per call."}],"x-tagGroups":[{"name":"Create","tags":["Videos","Images","Captions","Music","Voice","Transcripts"]},{"name":"Your assets","tags":["Uploads","Products"]},{"name":"Track","tags":["Jobs"]},{"name":"Look up","tags":["Reference","Competitor ads","Analysis"]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"http","scheme":"bearer","description":"Send your key as `Authorization: Bearer novo_…`.\n\nCreate one in the Novoads dashboard under Settings → Developer. The key is shown once, at creation, and cannot be retrieved afterwards — store it in your secret manager immediately. Revoking a key takes effect on the next request.\n\nKeys are scoped to the organization that created them and act as the user who created them. Every job, asset and upload is looked up by that organization, so another organization's id returns 404 rather than 403 — \"not found\" and \"not yours\" are deliberately indistinguishable."}},"schemas":{"Upload":{"type":"object","properties":{"assetId":{"type":"string","description":"Identifies the file in later calls (`startImageAssetId`, `referenceAssetIds`, `assetId`). Scoped to your organization; another organization's id resolves to 404."},"uploadUrl":{"type":"string","format":"uri","description":"Presigned URL. PUT the raw bytes to it."},"method":{"type":"string","enum":["PUT"],"description":"The HTTP method `uploadUrl` was signed for."},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Headers the PUT must send, byte for byte. Both are part of the URL's signature, so storage rejects the upload with 403 if either differs — a different Content-Type, an added `; charset=…`, an omitted header, or a body of another length. Echo this object rather than letting an HTTP client infer the type from the file."},"expiresInSeconds":{"type":"integer","description":"How long `uploadUrl` stays valid. Request a new one rather than retrying an expired PUT."},"maxBytes":{"type":"integer","description":"Largest file this endpoint accepts."}},"required":["assetId","uploadUrl","method","headers","expiresInSeconds","maxBytes"]},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","invalid_input","insufficient_credits","content_policy","unsupported_media","rate_limited","conflict","method_not_allowed","provider_failed","internal_error"],"description":"Stable machine-readable reason. Branch on this, never on `message`."},"message":{"type":"string","description":"Human-readable explanation. Wording may change."},"requestId":{"type":"string","description":"Same value as the `x-request-id` response header. Quote it in a support request and the whole server-side trace is one lookup."},"details":{"type":"object","additionalProperties":{},"description":"Code-specific context — `issues` for a validation failure, `required`/`available` for `insufficient_credits`."}},"required":["code","message","requestId"]}},"required":["error"]},"CreateUploadRequest":{"type":"object","properties":{"contentType":{"type":"string","enum":["image/jpeg","image/png","image/webp","video/mp4","video/quicktime","video/webm"],"description":"MIME type of the file you are about to PUT."},"sizeBytes":{"type":"integer","exclusiveMinimum":0,"maximum":104857600,"description":"Exact size of the file in bytes. Maximum 104857600."}},"required":["contentType","sizeBytes"],"additionalProperties":false},"VideoJob":{"type":"object","properties":{"jobId":{"type":"string","description":"Poll it at GET /v1/generations/{jobId}."},"status":{"$ref":"#/components/schemas/GenerationStatus"},"creditsCharged":{"type":"number","description":"Credits debited for this call, in the same unit shown on the billing page (1 credit = 10 centi-credits internally). Refunded automatically if the provider fails after the charge."},"model":{"type":"string","enum":["seedance-2.0","seedance-2.5","seedance-2.0-mini","omni-flash","veo-3.1","sora-2"],"description":"The model that will render this job — echoed back so a caller who omitted `model` learns which default it got, without inferring it."}},"required":["jobId","status","creditsCharged","model"]},"GenerationStatus":{"type":"string","enum":["queued","running","finalizing","succeeded","failed","blocked","canceled"],"description":"Where the job is. `queued` means charged and submitted but not yet rendering — normal, not a stall. `succeeded`, `failed`, `blocked` and `canceled` are terminal; nothing else will change.","x-enumDescriptions":{"queued":"Accepted, charged and submitted, but the provider has not started rendering it yet. Normal, not a stall — most jobs sit here for a while.","running":"The provider is rendering it. Typically 4 to 7 minutes for video.","finalizing":"The provider is done and we are storing the file. Seconds, not minutes. You see this only if you poll during that window.","succeeded":"Terminal. The file exists: `outputUrl` is populated and `GET /generations/{jobId}/watch` will redirect to it.","failed":"Terminal. No file was produced. Credits charged for the attempt are refunded automatically, and retrying is reasonable.","blocked":"Terminal. The provider's content classifier refused it. Refunded like a failure, but retrying the same prompt only pays twice — change the prompt.","canceled":"Terminal. Stopped before it produced a file."}},"CreateVideoRequest":{"oneOf":[{"$ref":"#/components/schemas/CreateVideoRequestSeedance20"},{"$ref":"#/components/schemas/CreateVideoRequestSeedance25"},{"$ref":"#/components/schemas/CreateVideoRequestSeedance20Mini"},{"$ref":"#/components/schemas/CreateVideoRequestOmniFlash"},{"$ref":"#/components/schemas/CreateVideoRequestVeo31"},{"$ref":"#/components/schemas/CreateVideoRequestSora2"}],"discriminator":{"propertyName":"model","mapping":{"seedance-2.0":"#/components/schemas/CreateVideoRequestSeedance20","seedance-2.5":"#/components/schemas/CreateVideoRequestSeedance25","seedance-2.0-mini":"#/components/schemas/CreateVideoRequestSeedance20Mini","omni-flash":"#/components/schemas/CreateVideoRequestOmniFlash","veo-3.1":"#/components/schemas/CreateVideoRequestVeo31","sora-2":"#/components/schemas/CreateVideoRequestSora2"}}},"CreateVideoRequestSeedance20":{"type":"object","properties":{"model":{"type":"string","enum":["seedance-2.0"],"description":"Seedance 2.0. Also the default — omit `model` and this is what renders.","example":"seedance-2.0"},"prompt":{"type":"string","minLength":1,"maxLength":4000,"description":"What the video should show, as flowing prose in the ad's own language. Up to 4,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`); nothing is rejected for wording, structure, or missing a described actor. `POST /v1/estimates` will lint a prompt against our craft rules for free and report what it finds as advice — worth a call, and safe to ignore.","example":"A woman in her early thirties stands in a sunlit kitchen, soft window light from camera-left, visible skin texture. She holds up the serum bottle and says: \"I stopped buying the other one.\" The product label remains perfectly sharp and identical to the reference image with its text unchanged and fully legible. Vertical 9:16."},"durationSeconds":{"type":"integer","enum":[4,5,6,7,8,9,10,11,12,13,14,15],"example":5,"description":"Video length. This model renders exactly 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 seconds; defaults to 5. Any other value is rejected — it is not rounded to the nearest one. Longer costs more; price it with /v1/estimates."},"aspectRatio":{"type":"string","enum":["16:9","9:16","1:1","4:3","3:4","21:9"],"description":"Defaults to 16:9. Pass 9:16 for Reels, TikTok and Stories.","example":"9:16","x-enumDescriptions":{"16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player.","9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","1:1":"Square. Feed placements, and the safe choice when one asset has to run in several of them.","4:3":"Classic landscape, close to square. Rarely what a paid placement wants.","3:4":"Classic portrait, close to square.","21:9":"Ultra-wide, letterboxed. A cinematic look; no social placement serves it natively."}},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"The ad's language, declared rather than inferred. **Defaults to `en`.** It does not change what the model is sent, and it does not change the price — the spoken language comes from the quoted line in your prompt. It is RECORDED against the job, which is the only reason to send it: it makes \"how do our Spanish ads perform?\" answerable. Nothing rejects a prompt for disagreeing with it.","example":"en"},"startImageAssetId":{"type":"string","description":"`assetId` from POST /v1/uploads, or the `images[].assetId` of a still POST /v1/images just generated — animated as the first frame. Omit it and the model composes the opening frame from the prompt alone. Cannot be combined with `referenceAssetIds` — they are separate modes, and sending both is an error rather than a silently-ignored field."},"resolution":{"type":"string","enum":["480p","720p","1080p","4k"],"description":"Output resolution. **Defaults to 720p**, which is what this model rendered before the field existed.\n\n**This one changes the price**, unlike `aspectRatio`: each tier is its own credit schedule, not a surcharge on the one below it. At this model's default 5 seconds the same render costs 480p 1.5, 720p 3, 1080p 7.5, 4k 15.5 credits. Longer clips move every cell, so price the exact one with `POST /v1/estimates` before you commit to it.\n\nAsk for what you will actually ship. 720p is the right answer for Reels, TikTok and Stories, where the platform re-encodes anyway; 1080p and 4k are for finals that go to a client, a placement with a quality floor, or a crop. 480p is the draft tier — cheaper, and priced as such.\n\nThis deployment renders 480p, 720p, 1080p, 4k for this model. That set is not fixed forever — read it from `GET /v1/models` rather than hardcoding it, and expect a value outside it to be a `400` rather than a downscale you were charged the higher price for.","example":"720p","x-enumDescriptions":{"480p":"Lowest tier. Priced the same as 720p, so there is no reason to ask for it.","720p":"The default, and the right answer for Reels, TikTok and Stories — the platform re-encodes anyway.","1080p":"Its own credit schedule, not a surcharge on 720p. For a final that goes to a client, a placement with a quality floor, or a crop.","4k":"The highest tier, and its own credit schedule again. Ask for it only if you will actually ship it."}},"referenceAssetIds":{"type":"array","items":{"type":"string"},"maxItems":9,"description":"`assetId`s from POST /v1/uploads — or straight from a POST /v1/images response, whose `images[].assetId` is accepted here with no download-and-re-upload hop — composited as visual references (a character, a product, a wardrobe, a setting) rather than animated as a first frame. Up to 9 for this model.\n\n**Order is the contract.** Address them in the prompt as `@Image1`, `@Image2` … in the order you send them; `@Image1` is the first id in the array. A token pointing past the end of the array is rejected before anything is charged, because the model treats an unresolvable reference as a content failure and a refunded render is a worse answer than a 400.\n\n**Images only** — `image/jpeg`, `image/png`, `image/webp`. `POST /v1/uploads` also accepts video, and a video `assetId` here is an error, not a reference: the providers price a render with video input differently while your credit cost here is a function of duration alone, so the quote and the invoice would stop agreeing.\n\nSelects the model's reference-to-video mode, so it cannot be combined with `startImageAssetId`."},"audioEnabled":{"type":"boolean","description":"Whether the render carries audio — the synchronized sound effects, ambient sound and lip-synced speech the model generates from your prompt. **Defaults to `true`**, which is what this endpoint rendered before the field existed.\n\nSend `false` for a silent clip: a pipeline that lays its own voice-over in post pays for a voice track it then throws away, and a product cutaway meant to run muted comes back with sound effects nobody hears.\n\nIt does not change the price, the length, or anything else about the grid — both of our Seedance providers charge the same either way, which is why `POST /v1/estimates` does not take this field.","example":true},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Seedance 2.0"},"CreateVideoRequestSeedance25":{"type":"object","properties":{"model":{"type":"string","enum":["seedance-2.5"],"description":"Seedance 2.5","example":"seedance-2.5"},"prompt":{"type":"string","minLength":1,"maxLength":4000,"description":"What the video should show, as flowing prose in the ad's own language. Up to 4,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`); nothing is rejected for wording, structure, or missing a described actor. `POST /v1/estimates` will lint a prompt against our craft rules for free and report what it finds as advice — worth a call, and safe to ignore.","example":"A woman in her early thirties stands in a sunlit kitchen, soft window light from camera-left, visible skin texture. She holds up the serum bottle and says: \"I stopped buying the other one.\" The product label remains perfectly sharp and identical to the reference image with its text unchanged and fully legible. Vertical 9:16."},"durationSeconds":{"type":"integer","enum":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"example":5,"description":"Video length. This model renders exactly 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30 seconds; defaults to 5. Any other value is rejected — it is not rounded to the nearest one. Longer costs more; price it with /v1/estimates."},"aspectRatio":{"type":"string","enum":["16:9","9:16","1:1","4:3","3:4","21:9"],"description":"Defaults to 16:9. Pass 9:16 for Reels, TikTok and Stories.","example":"9:16","x-enumDescriptions":{"16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player.","9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","1:1":"Square. Feed placements, and the safe choice when one asset has to run in several of them.","4:3":"Classic landscape, close to square. Rarely what a paid placement wants.","3:4":"Classic portrait, close to square.","21:9":"Ultra-wide, letterboxed. A cinematic look; no social placement serves it natively."}},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"The ad's language, declared rather than inferred. **Defaults to `en`.** It does not change what the model is sent, and it does not change the price — the spoken language comes from the quoted line in your prompt. It is RECORDED against the job, which is the only reason to send it: it makes \"how do our Spanish ads perform?\" answerable. Nothing rejects a prompt for disagreeing with it.","example":"en"},"startImageAssetId":{"type":"string","description":"`assetId` from POST /v1/uploads, or the `images[].assetId` of a still POST /v1/images just generated — animated as the first frame. Omit it and the model composes the opening frame from the prompt alone. Cannot be combined with `referenceAssetIds` — they are separate modes, and sending both is an error rather than a silently-ignored field."},"resolution":{"type":"string","enum":["480p","720p"],"description":"Output resolution. **Defaults to 720p**, which is what this model rendered before the field existed.\n\n**This one changes the price**, unlike `aspectRatio`: each tier is its own credit schedule, not a surcharge on the one below it. At this model's default 5 seconds the same render costs 480p 1.5, 720p 3 credits. Longer clips move every cell, so price the exact one with `POST /v1/estimates` before you commit to it.\n\nAsk for what you will actually ship. 720p is the right answer for Reels, TikTok and Stories, where the platform re-encodes anyway; 1080p and 4k are for finals that go to a client, a placement with a quality floor, or a crop. 480p is the draft tier — cheaper, and priced as such.\n\nThis deployment renders 480p, 720p for this model. That set is not fixed forever — read it from `GET /v1/models` rather than hardcoding it, and expect a value outside it to be a `400` rather than a downscale you were charged the higher price for.","example":"720p","x-enumDescriptions":{"480p":"Lowest tier. Priced the same as 720p, so there is no reason to ask for it.","720p":"The default, and the right answer for Reels, TikTok and Stories — the platform re-encodes anyway."}},"referenceAssetIds":{"type":"array","items":{"type":"string"},"maxItems":9,"description":"`assetId`s from POST /v1/uploads — or straight from a POST /v1/images response, whose `images[].assetId` is accepted here with no download-and-re-upload hop — composited as visual references (a character, a product, a wardrobe, a setting) rather than animated as a first frame. Up to 9 for this model.\n\n**Order is the contract.** Address them in the prompt as `@Image1`, `@Image2` … in the order you send them; `@Image1` is the first id in the array. A token pointing past the end of the array is rejected before anything is charged, because the model treats an unresolvable reference as a content failure and a refunded render is a worse answer than a 400.\n\n**Images only** — `image/jpeg`, `image/png`, `image/webp`. `POST /v1/uploads` also accepts video, and a video `assetId` here is an error, not a reference: the providers price a render with video input differently while your credit cost here is a function of duration alone, so the quote and the invoice would stop agreeing.\n\nSelects the model's reference-to-video mode, so it cannot be combined with `startImageAssetId`."},"audioEnabled":{"type":"boolean","description":"Whether the render carries audio — the synchronized sound effects, ambient sound and lip-synced speech the model generates from your prompt. **Defaults to `true`**, which is what this endpoint rendered before the field existed.\n\nSend `false` for a silent clip: a pipeline that lays its own voice-over in post pays for a voice track it then throws away, and a product cutaway meant to run muted comes back with sound effects nobody hears.\n\nIt does not change the price, the length, or anything else about the grid — both of our Seedance providers charge the same either way, which is why `POST /v1/estimates` does not take this field.","example":true},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Seedance 2.5"},"CreateVideoRequestSeedance20Mini":{"type":"object","properties":{"model":{"type":"string","enum":["seedance-2.0-mini"],"description":"Seedance 2.0 Mini","example":"seedance-2.0-mini"},"prompt":{"type":"string","minLength":1,"maxLength":4000,"description":"What the video should show, as flowing prose in the ad's own language. Up to 4,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`); nothing is rejected for wording, structure, or missing a described actor. `POST /v1/estimates` will lint a prompt against our craft rules for free and report what it finds as advice — worth a call, and safe to ignore.","example":"A woman in her early thirties stands in a sunlit kitchen, soft window light from camera-left, visible skin texture. She holds up the serum bottle and says: \"I stopped buying the other one.\" The product label remains perfectly sharp and identical to the reference image with its text unchanged and fully legible. Vertical 9:16."},"durationSeconds":{"type":"integer","enum":[4,5,6,7,8,9,10,11,12,13,14,15],"example":10,"description":"Video length. This model renders exactly 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 seconds; defaults to 10. Any other value is rejected — it is not rounded to the nearest one. Longer costs more; price it with /v1/estimates."},"aspectRatio":{"type":"string","enum":["16:9","9:16","1:1","4:3","3:4","21:9"],"description":"Defaults to 16:9. Pass 9:16 for Reels, TikTok and Stories.","example":"9:16","x-enumDescriptions":{"16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player.","9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","1:1":"Square. Feed placements, and the safe choice when one asset has to run in several of them.","4:3":"Classic landscape, close to square. Rarely what a paid placement wants.","3:4":"Classic portrait, close to square.","21:9":"Ultra-wide, letterboxed. A cinematic look; no social placement serves it natively."}},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"The ad's language, declared rather than inferred. **Defaults to `en`.** It does not change what the model is sent, and it does not change the price — the spoken language comes from the quoted line in your prompt. It is RECORDED against the job, which is the only reason to send it: it makes \"how do our Spanish ads perform?\" answerable. Nothing rejects a prompt for disagreeing with it.","example":"en"},"startImageAssetId":{"type":"string","description":"`assetId` from POST /v1/uploads, or the `images[].assetId` of a still POST /v1/images just generated — animated as the first frame. Omit it and the model composes the opening frame from the prompt alone. Cannot be combined with `referenceAssetIds` — they are separate modes, and sending both is an error rather than a silently-ignored field."},"referenceAssetIds":{"type":"array","items":{"type":"string"},"maxItems":9,"description":"`assetId`s from POST /v1/uploads — or straight from a POST /v1/images response, whose `images[].assetId` is accepted here with no download-and-re-upload hop — composited as visual references (a character, a product, a wardrobe, a setting) rather than animated as a first frame. Up to 9 for this model.\n\n**Order is the contract.** Address them in the prompt as `@Image1`, `@Image2` … in the order you send them; `@Image1` is the first id in the array. A token pointing past the end of the array is rejected before anything is charged, because the model treats an unresolvable reference as a content failure and a refunded render is a worse answer than a 400.\n\n**Images only** — `image/jpeg`, `image/png`, `image/webp`. `POST /v1/uploads` also accepts video, and a video `assetId` here is an error, not a reference: the providers price a render with video input differently while your credit cost here is a function of duration alone, so the quote and the invoice would stop agreeing.\n\nSelects the model's reference-to-video mode, so it cannot be combined with `startImageAssetId`."},"audioEnabled":{"type":"boolean","description":"Whether the render carries audio — the synchronized sound effects, ambient sound and lip-synced speech the model generates from your prompt. **Defaults to `true`**, which is what this endpoint rendered before the field existed.\n\nSend `false` for a silent clip: a pipeline that lays its own voice-over in post pays for a voice track it then throws away, and a product cutaway meant to run muted comes back with sound effects nobody hears.\n\nIt does not change the price, the length, or anything else about the grid — both of our Seedance providers charge the same either way, which is why `POST /v1/estimates` does not take this field.","example":true},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Seedance 2.0 Mini"},"CreateVideoRequestOmniFlash":{"type":"object","properties":{"model":{"type":"string","enum":["omni-flash"],"description":"Omni Flash","example":"omni-flash"},"prompt":{"type":"string","minLength":1,"maxLength":20000,"description":"What the video should show, as flowing prose in the ad's own language. Up to 20,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`); nothing is rejected for wording, structure, or missing a described actor. `POST /v1/estimates` will lint a prompt against our craft rules for free and report what it finds as advice — worth a call, and safe to ignore.","example":"A woman in her early thirties stands in a sunlit kitchen, soft window light from camera-left, visible skin texture. She holds up the serum bottle and says: \"I stopped buying the other one.\" The product label remains perfectly sharp and identical to the reference image with its text unchanged and fully legible. Vertical 9:16."},"durationSeconds":{"type":"integer","enum":[4,6,8,10],"example":8,"description":"Video length. This model renders exactly 4, 6, 8, 10 seconds; defaults to 8. Any other value is rejected — it is not rounded to the nearest one. Longer costs more; price it with /v1/estimates."},"aspectRatio":{"type":"string","enum":["9:16","16:9"],"description":"Defaults to 9:16 — the shape Reels, TikTok and Stories run in.","example":"9:16","x-enumDescriptions":{"9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player."}},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"The ad's language, declared rather than inferred. **Defaults to `en`.** It does not change what the model is sent, and it does not change the price — the spoken language comes from the quoted line in your prompt. It is RECORDED against the job, which is the only reason to send it: it makes \"how do our Spanish ads perform?\" answerable. Nothing rejects a prompt for disagreeing with it.","example":"en"},"startImageAssetId":{"type":"string","description":"`assetId` from POST /v1/uploads, or the `images[].assetId` of a still POST /v1/images just generated — animated as the first frame. Omit it and the model composes the opening frame from the prompt alone."},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Omni Flash"},"CreateVideoRequestVeo31":{"type":"object","properties":{"model":{"type":"string","enum":["veo-3.1"],"description":"Veo 3.1","example":"veo-3.1"},"prompt":{"type":"string","minLength":1,"maxLength":4000,"description":"What the video should show, as flowing prose in the ad's own language. Up to 4,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`); nothing is rejected for wording, structure, or missing a described actor. `POST /v1/estimates` will lint a prompt against our craft rules for free and report what it finds as advice — worth a call, and safe to ignore.","example":"A woman in her early thirties stands in a sunlit kitchen, soft window light from camera-left, visible skin texture. She holds up the serum bottle and says: \"I stopped buying the other one.\" The product label remains perfectly sharp and identical to the reference image with its text unchanged and fully legible. Vertical 9:16."},"durationSeconds":{"type":"integer","enum":[4,6,8],"example":8,"description":"Video length. This model renders exactly 4, 6, 8 seconds; defaults to 8. Any other value is rejected — it is not rounded to the nearest one. Longer costs more; price it with /v1/estimates."},"aspectRatio":{"type":"string","enum":["9:16","16:9"],"description":"Defaults to 9:16 — the shape Reels, TikTok and Stories run in.","example":"9:16","x-enumDescriptions":{"9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player."}},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"The ad's language, declared rather than inferred. **Defaults to `en`.** It does not change what the model is sent, and it does not change the price — the spoken language comes from the quoted line in your prompt. It is RECORDED against the job, which is the only reason to send it: it makes \"how do our Spanish ads perform?\" answerable. Nothing rejects a prompt for disagreeing with it.","example":"en"},"startImageAssetId":{"type":"string","description":"`assetId` from POST /v1/uploads, or the `images[].assetId` of a still POST /v1/images just generated — animated as the first frame. Omit it and the model composes the opening frame from the prompt alone."},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Veo 3.1"},"CreateVideoRequestSora2":{"type":"object","properties":{"model":{"type":"string","enum":["sora-2"],"description":"Sora 2","example":"sora-2"},"prompt":{"type":"string","minLength":1,"maxLength":4000,"description":"What the video should show, as flowing prose in the ad's own language. Up to 4,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`); nothing is rejected for wording, structure, or missing a described actor. `POST /v1/estimates` will lint a prompt against our craft rules for free and report what it finds as advice — worth a call, and safe to ignore.","example":"A woman in her early thirties stands in a sunlit kitchen, soft window light from camera-left, visible skin texture. She holds up the serum bottle and says: \"I stopped buying the other one.\" The product label remains perfectly sharp and identical to the reference image with its text unchanged and fully legible. Vertical 9:16."},"durationSeconds":{"type":"integer","enum":[4,8,12],"example":4,"description":"Video length. This model renders exactly 4, 8, 12 seconds; defaults to 4. Any other value is rejected — it is not rounded to the nearest one. Longer costs more; price it with /v1/estimates."},"aspectRatio":{"type":"string","enum":["9:16","16:9"],"description":"Defaults to 9:16 — the shape Reels, TikTok and Stories run in.","example":"9:16","x-enumDescriptions":{"9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player."}},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"The ad's language, declared rather than inferred. **Defaults to `en`.** It does not change what the model is sent, and it does not change the price — the spoken language comes from the quoted line in your prompt. It is RECORDED against the job, which is the only reason to send it: it makes \"how do our Spanish ads perform?\" answerable. Nothing rejects a prompt for disagreeing with it.","example":"en"},"startImageAssetId":{"type":"string","description":"`assetId` from POST /v1/uploads, or the `images[].assetId` of a still POST /v1/images just generated — animated as the first frame. Omit it and the model composes the opening frame from the prompt alone."},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Sora 2"},"ImageJob":{"type":"object","properties":{"jobId":{"type":"string","description":"The job. Images are already finished when this returns, so polling is not required — but the id is how you recover the result if the response is lost."},"status":{"$ref":"#/components/schemas/GenerationStatus"},"creditsCharged":{"type":"number","description":"Credits debited for this call, in the same unit shown on the billing page (1 credit = 10 centi-credits internally). Refunded automatically if the provider fails after the charge."},"model":{"type":"string","enum":["gpt-image-2","nano-banana-pro","reve-2.1"],"description":"The model that rendered this job — echoed back so a caller who omitted `model` learns which default it got, without inferring it."},"images":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Presigned download URL."},"expiresInSeconds":{"type":"integer","description":"How long `url` stays valid (3600 seconds). Re-read the job for a fresh one."},"assetId":{"type":"string","description":"This image as an asset id, usable directly in a follow-up call: `referenceAssetIds` or `startImageAssetId` on POST /v1/videos, and `referenceAssetIds` or `sourceAssetId` on POST /v1/images. It does NOT expire the way `url` does — chain from this, not from the URL, and there is no need to download the image and re-upload it through POST /v1/uploads to reference it. Optional in the schema so a provider that ever writes outside its registered namespace yields no id rather than one that would be refused; every model this deployment serves returns it."},"width":{"type":"integer"},"height":{"type":"integer"}},"required":["url","expiresInSeconds","width","height"]}}},"required":["jobId","status","creditsCharged","model","images"]},"CreateImageRequest":{"oneOf":[{"$ref":"#/components/schemas/CreateImageRequestGptImage2"},{"$ref":"#/components/schemas/CreateImageRequestNanoBananaPro"},{"$ref":"#/components/schemas/CreateImageRequestReve21"}],"discriminator":{"propertyName":"model","mapping":{"gpt-image-2":"#/components/schemas/CreateImageRequestGptImage2","nano-banana-pro":"#/components/schemas/CreateImageRequestNanoBananaPro","reve-2.1":"#/components/schemas/CreateImageRequestReve21"}}},"CreateImageRequestGptImage2":{"type":"object","properties":{"model":{"type":"string","enum":["gpt-image-2"],"description":"GPT Image 2. Also the default — omit `model` and this is what renders.","example":"gpt-image-2"},"prompt":{"type":"string","minLength":1,"maxLength":32000,"description":"What the image should show, as flowing prose. Up to 32,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`). `POST /v1/estimates` lints a prompt for free and reports what it finds as advice.","example":"A single serum bottle on a pale marble counter, morning window light from camera-left, soft shadow falling to the right, a few water droplets on the glass."},"aspectRatio":{"type":"string","enum":["1:1","4:5","2:3","9:16","16:9","21:9"],"description":"Defaults to 1:1.","example":"9:16","x-enumDescriptions":{"1:1":"Square. Feed placements, and the safe choice when one asset has to run in several of them.","4:5":"Portrait, but not full height — taller than square, shorter than a Story.","2:3":"Portrait, the 35mm photo shape turned upright.","9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player.","21:9":"Ultra-wide, letterboxed. A cinematic look; no social placement serves it natively."}},"referenceAssetIds":{"type":"array","items":{"type":"string"},"maxItems":4,"description":"`assetId`s from POST /v1/uploads, or `images[].assetId` from an earlier POST /v1/images — pinned as visual references. Up to 4 for this model. Order is preserved and may be addressed positionally by the prompt. Chaining each still on the previous one is how a character is held across separately-rendered images."},"numImages":{"type":"integer","enum":[1,2,3,4],"example":1,"description":"How many images to generate. Defaults to 1, up to 4. Charged per image, so this multiplies the price."},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."},"sourceAssetId":{"type":"string","minLength":1,"description":"An `assetId` — from POST /v1/uploads, or the `images[].assetId` of an image this API generated — to EDIT rather than a blank canvas to draw on. The prompt then describes the CHANGE — \"remove the logo on the bottle\", \"make the background a kitchen counter\" — not the whole image.\n\n**The output tracks the source's shape**, so `aspectRatio` is not accepted alongside it: sending both is a `400` rather than a silently-ignored field. The rendered size is the cell of this model's grid closest to the source's own ratio — *closest*, not exact: this grid is dense in portrait and has nothing between `1:1` and `16:9`, so a 4:3 landscape source (the common phone/camera shape) renders `1:1`. Portrait sources are never rendered landscape or vice versa.\n\n`referenceAssetIds` may still be sent — the source counts as one of them against the cap, and it is always first.\n\nPriced exactly like a generation of the same size: an edit is one image, charged once."}},"required":["model","prompt"],"additionalProperties":false,"title":"GPT Image 2"},"CreateImageRequestNanoBananaPro":{"type":"object","properties":{"model":{"type":"string","enum":["nano-banana-pro"],"description":"Nano Banana Pro","example":"nano-banana-pro"},"prompt":{"type":"string","minLength":1,"maxLength":50000,"description":"What the image should show, as flowing prose. Up to 50,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`). `POST /v1/estimates` lints a prompt for free and reports what it finds as advice.","example":"A single serum bottle on a pale marble counter, morning window light from camera-left, soft shadow falling to the right, a few water droplets on the glass."},"aspectRatio":{"type":"string","enum":["1:1","2:3","3:2","3:4","4:3","4:5","5:4","9:16","16:9","21:9"],"description":"Defaults to 1:1.","example":"9:16","x-enumDescriptions":{"1:1":"Square. Feed placements, and the safe choice when one asset has to run in several of them.","2:3":"Portrait, the 35mm photo shape turned upright.","3:2":"Landscape, the 35mm photo shape.","3:4":"Classic portrait, close to square.","4:3":"Classic landscape, close to square. Rarely what a paid placement wants.","4:5":"Portrait, but not full height — taller than square, shorter than a Story.","5:4":"Landscape, barely wider than square.","9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player.","21:9":"Ultra-wide, letterboxed. A cinematic look; no social placement serves it natively."}},"referenceAssetIds":{"type":"array","items":{"type":"string"},"maxItems":14,"description":"`assetId`s from POST /v1/uploads, or `images[].assetId` from an earlier POST /v1/images — pinned as visual references. Up to 14 for this model. Order is preserved and may be addressed positionally by the prompt. Chaining each still on the previous one is how a character is held across separately-rendered images."},"numImages":{"type":"integer","enum":[1,2,3,4],"example":1,"description":"How many images to generate. Defaults to 1, up to 4. Charged per image, so this multiplies the price."},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Nano Banana Pro"},"CreateImageRequestReve21":{"type":"object","properties":{"model":{"type":"string","enum":["reve-2.1"],"description":"Reve 2.1","example":"reve-2.1"},"prompt":{"type":"string","minLength":1,"maxLength":4000,"description":"What the image should show, as flowing prose. Up to 4,000 characters for this model.\n\nNo house style is enforced. The only prompt check that can refuse this call is content moderation (`422`). `POST /v1/estimates` lints a prompt for free and reports what it finds as advice.","example":"A single serum bottle on a pale marble counter, morning window light from camera-left, soft shadow falling to the right, a few water droplets on the glass."},"aspectRatio":{"type":"string","enum":["1:1","2:3","3:2","3:4","4:3","4:5","5:4","9:16","16:9","21:9"],"description":"Defaults to 1:1.","example":"9:16","x-enumDescriptions":{"1:1":"Square. Feed placements, and the safe choice when one asset has to run in several of them.","2:3":"Portrait, the 35mm photo shape turned upright.","3:2":"Landscape, the 35mm photo shape.","3:4":"Classic portrait, close to square.","4:3":"Classic landscape, close to square. Rarely what a paid placement wants.","4:5":"Portrait, but not full height — taller than square, shorter than a Story.","5:4":"Landscape, barely wider than square.","9:16":"Vertical, full screen. Reels, TikTok, Shorts and Stories — the shape most ads generated here ship in.","16:9":"Widescreen. YouTube in-stream, landscape placements, and anything watched on a desktop player.","21:9":"Ultra-wide, letterboxed. A cinematic look; no social placement serves it natively."}},"referenceAssetIds":{"type":"array","items":{"type":"string"},"maxItems":8,"description":"`assetId`s from POST /v1/uploads, or `images[].assetId` from an earlier POST /v1/images — pinned as visual references. Up to 8 for this model. Order is preserved and may be addressed positionally by the prompt. Chaining each still on the previous one is how a character is held across separately-rendered images."},"numImages":{"type":"integer","enum":[1,2,3,4],"example":1,"description":"How many images to generate. Defaults to 1, up to 4. Charged per image, so this multiplies the price."},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["model","prompt"],"additionalProperties":false,"title":"Reve 2.1"},"MusicJob":{"type":"object","properties":{"jobId":{"type":"string","description":"Poll it at GET /v1/generations/{jobId}."},"status":{"$ref":"#/components/schemas/GenerationStatus"},"creditsCharged":{"type":"number","description":"Credits debited for this call, in the same unit shown on the billing page (1 credit = 10 centi-credits internally). Refunded automatically if the provider fails after the charge."},"model":{"type":"string","enum":["music-v1"],"description":"What renders the track. Fixed — this endpoint has one renderer, and it is not a model `GET /v1/models` offers, because that endpoint's `kind` is `video | image`."}},"required":["jobId","status","creditsCharged","model"]},"CreateMusicRequest":{"type":"object","properties":{"prompt":{"type":"string","minLength":1,"maxLength":500,"description":"What the track should sound like, as prose — instrumentation, mood, tempo, and what it sits under.\n\nThe provider's ceiling is 500 characters and it applies to the COMPOSED prompt, not to this field alone: `style`, the instrumental instruction and `durationHintSeconds` are folded into the same string before it is submitted. Leave headroom — a 500-character prompt with the defaults does not fit, and is refused with `400` before anything is charged.\n\nREQUIRED, with no default: a default prompt is a default render, and you would be charged for it.","example":"warm lofi hip-hop, calm and mellow, soft rhodes and vinyl crackle, sits under a voice-over without competing with it"},"style":{"type":"string","maxLength":200,"description":"Style guidance, folded INTO the prompt rather than sent as a separate field. The provider exposes `style` only in its Custom Mode, which this endpoint does not use, so publishing it as a passthrough would be advertising a knob that cannot turn. Treat it as prompt sugar: \"lofi instrumental\" is a reasonable value and writing the same words into `prompt` does exactly the same thing.","example":"lofi instrumental"},"instrumental":{"type":"boolean","description":"Render without vocals. Defaults to **true** — this is an ad bed, and lyrics compete with your voice-over for the same attention. Set `false` only if you want a sung track."},"durationHintSeconds":{"type":"integer","minimum":5,"maximum":180,"description":"**Advisory only.** The provider takes no duration parameter, so this is folded into the prompt text as a preference and the model does what it does — expect anything from about one to two minutes. It does **not** change the price, because the provider bills us per request rather than per second. Trim the result yourself."},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["prompt"],"additionalProperties":false},"Voiceover":{"type":"object","properties":{"jobId":{"type":"string","description":"The generation record. Nothing to poll — it is already terminal — but this is how you find this call again in GET /v1/generations if the response never reached you."},"assetId":{"type":"string","description":"The stored audio in your asset library."},"url":{"type":"string","format":"uri","description":"Time-limited download URL for the mp3. Minted per response, never stored."},"expiresInSeconds":{"type":"integer","description":"How long `url` stays valid."},"creditsCharged":{"type":"number","description":"Credits debited for this call, in the same unit shown on the billing page (1 credit = 10 centi-credits internally). Refunded automatically if the provider fails after the charge."},"characters":{"type":"integer","description":"Characters actually billed — the trimmed script's length."},"voiceId":{"type":"string","description":"Echoed so a caller rendering several lines at once can pair result to request."}},"required":["jobId","assetId","url","expiresInSeconds","creditsCharged","characters","voiceId"]},"CreateVoiceoverRequest":{"type":"object","properties":{"script":{"type":"string","minLength":1,"maxLength":1000,"description":"The words to speak, as plain text. Rendered VERBATIM — anything in square brackets is read as a performance tag by the model rather than skipped, so do not put stage directions in the script.\n\nUp to 1000 characters, about 40 seconds of speech. A longer script is refused with `400` before anything is charged rather than rendered as one long take: split it into one call per line, which is what you want anyway if you are laying narration into the gaps between clips.\n\nBilled per 100 characters of the TRIMMED script, rounded up, minimum 1 centi-credit. Price it first with `POST /v1/estimates`.","example":"Mornings deserve better than waiting."},"voiceId":{"type":"string","minLength":1,"description":"Which voice speaks it. **REQUIRED, with no default** — a default voice is a performance you would be charged for without hearing it, and it would change under you whenever the catalog is re-synced. Get one from `GET /v1/voices`, which lists the platform voices plus your own organization's clones. An id belonging to another organization, or to a voice that has gone inactive upstream, is a `404`."},"language":{"type":"string","minLength":2,"maxLength":16,"description":"ISO 639-1 code for the language to speak in, e.g. `es`. Validated against the chosen voice's own recorded languages — a mismatch is a named `400` rather than a render in the wrong accent that you already paid for. Voices with no recorded languages (typically your own clones) accept any value. Omit it and the model infers the language from the script's own text, which is what the dashboard does.","example":"es"},"productId":{"type":"string","description":"Files the job under one of your products (`GET /v1/products`). Omit it and the job lands in your default product, which is where every job landed before this field existed.\n\nOrganizational only: a product does NOT influence what is generated. None of its fields reach the prompt."}},"required":["script","voiceId"],"additionalProperties":false},"VoiceList":{"type":"object","properties":{"voices":{"type":"array","items":{"$ref":"#/components/schemas/Voice"}}},"required":["voices"]},"Voice":{"type":"object","properties":{"id":{"type":"string","description":"Pass this as `voiceId`."},"name":{"type":"string"},"source":{"type":"string","enum":["platform","organization"],"description":"`platform` is one of ours, available to everyone. `organization` is one your organization cloned or designed. Nobody else's clones are ever listed."},"languages":{"type":"array","items":{"type":"string"},"description":"Language codes this voice is known to speak, mixing bare ISO 639-1 codes and regional tags. Absent when unrecorded, which is normal for a cloned voice — those accept any `language`."},"primaryLanguage":{"type":"string"},"category":{"type":"string","description":"The provider's own category: `premade`, `cloned`, `professional`, …"},"labels":{"type":"object","additionalProperties":{"type":"string"},"description":"Free-form descriptors — accent, gender, age — passed through as recorded."}},"required":["id","name","source"]},"Estimate":{"type":"object","properties":{"credits":{"type":"number","description":"What the generation would cost. Spends nothing."},"balance":{"type":"number","description":"Your organization's balance at the moment this was read. A SNAPSHOT, not a reservation: it can change before you generate, and the debit at generation time stays authoritative."},"sufficient":{"type":"boolean","description":"`balance >= credits` at that moment."},"shortBy":{"type":"number","description":"How far short, when it is short."},"topUpUrl":{"type":"string","format":"uri","description":"Where to add credits, when it is short."},"warnings":{"type":"array","items":{"$ref":"#/components/schemas/PromptWarning"},"description":"Craft advice from our own prompt rules, run for free against the prompt you sent. **Every one of these is advisory** — none of them will refuse a generation, none of them changes the price, and a prompt that trips all of them renders exactly the same as one that trips none. They are here because this endpoint costs nothing and is called first, so the advice is free to collect and free to ignore. The generation endpoints do not run these rules at all."}},"required":["credits","balance","sufficient"]},"PromptWarning":{"type":"object","properties":{"rule":{"type":"string","description":"Identifier of the prompt rule that fired."},"message":{"type":"string","description":"What to change, and why it matters."}},"required":["rule","message"]},"CreateEstimateRequest":{"oneOf":[{"$ref":"#/components/schemas/CreateEstimateRequestVideo"},{"$ref":"#/components/schemas/CreateEstimateRequestImage"},{"$ref":"#/components/schemas/CreateEstimateRequestCaption"},{"$ref":"#/components/schemas/CreateEstimateRequestMusic"},{"$ref":"#/components/schemas/CreateEstimateRequestTranscript"},{"$ref":"#/components/schemas/CreateEstimateRequestVoiceover"},{"$ref":"#/components/schemas/CreateEstimateRequestCompetitorAds"},{"$ref":"#/components/schemas/CreateEstimateRequestAnalysis"}],"discriminator":{"propertyName":"kind","mapping":{"video":"#/components/schemas/CreateEstimateRequestVideo","image":"#/components/schemas/CreateEstimateRequestImage","caption":"#/components/schemas/CreateEstimateRequestCaption","music":"#/components/schemas/CreateEstimateRequestMusic","transcript":"#/components/schemas/CreateEstimateRequestTranscript","voiceover":"#/components/schemas/CreateEstimateRequestVoiceover","competitor-ads":"#/components/schemas/CreateEstimateRequestCompetitorAds","analysis":"#/components/schemas/CreateEstimateRequestAnalysis"}}},"CreateEstimateRequestVideo":{"type":"object","properties":{"prompt":{"type":"string","minLength":1,"maxLength":20000,"description":"The same prompt the generation would use. Up to 20,000 characters for this kind — the ceiling of the most permissive model it can price, with each model's own enforced when it is chosen. Priced identically whatever it says — length is the only property of it this endpoint enforces — and linted, with every finding returned in `warnings`."},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"Matches the generation request."},"kind":{"type":"string","enum":["video"],"description":"Price a POST /v1/videos call."},"model":{"type":"string","enum":["seedance-2.0","seedance-2.5","seedance-2.0-mini","omni-flash","veo-3.1","sora-2"],"description":"Which video model to price; defaults to seedance-2.0, matching POST /v1/videos. Not cosmetic — at four seconds the schedules already span more than 10× across this set, and across every cell this deployment publishes they span more than 28× (the cheapest four-second render against the longest, largest one), so pricing the wrong one is a quote that disagrees with the invoice."},"durationSeconds":{"type":"integer","minimum":4,"maximum":30,"description":"The outer bound across every model — 4 to 30 seconds — and no model renders all of it. Each accepts a narrower set (see GET /v1/models), enforced here by the same check the generation runs: only `seedance-2.5` goes past 15 seconds, and asking `seedance-2.0` for 20 is an error rather than a quote for something it cannot render. A duration the chosen model does not render is an error, not a rounded quote."},"resolution":{"type":"string","enum":["480p","720p","1080p","4k"],"description":"The second price axis. Defaults to the chosen model's own default — see `defaultResolution` on GET /v1/models, which is 720p for most of this set and 1080p for Veo 3.1. It moves the price on `seedance-2.0` and `seedance-2.5`: each tier is its own credit schedule rather than a surcharge on the one below it, so an estimate that omits it is a quote at that model's default and nothing else. On the rest of the set the field is accepted only at the model's single resolution. A resolution the chosen model does not render is an error, not a downgraded quote.","x-enumDescriptions":{"480p":"Lowest tier. Priced the same as 720p, so there is no reason to ask for it.","720p":"The default, and the right answer for Reels, TikTok and Stories — the platform re-encodes anyway.","1080p":"Its own credit schedule, not a surcharge on 720p. For a final that goes to a client, a placement with a quality floor, or a crop.","4k":"The highest tier, and its own credit schedule again. Ask for it only if you will actually ship it."}}},"required":["prompt","kind"],"additionalProperties":false},"CreateEstimateRequestImage":{"type":"object","properties":{"prompt":{"type":"string","minLength":1,"maxLength":50000,"description":"The same prompt the generation would use. Up to 50,000 characters for this kind — the ceiling of the most permissive model it can price, with each model's own enforced when it is chosen. Priced identically whatever it says — length is the only property of it this endpoint enforces — and linted, with every finding returned in `warnings`."},"language":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"Matches the generation request."},"kind":{"type":"string","enum":["image"],"description":"Price a POST /v1/images call."},"model":{"type":"string","enum":["gpt-image-2","nano-banana-pro","reve-2.1"],"description":"Which image model to price; defaults to gpt-image-2, matching POST /v1/images. The schedules differ by more than 3× across this set, so pricing the wrong one is a quote that disagrees with the invoice."},"numImages":{"type":"integer","minimum":1,"maximum":4,"description":"How many images to price. Defaults to 1. Every model charges per image."},"sourceAssetId":{"type":"string","minLength":1,"description":"Set when pricing an EDIT (`sourceAssetId` on POST /v1/images). The asset is not read here — only the shape of the call is — so this never needs to be a real id to get a quote, and pricing one costs nothing either way."}},"required":["prompt","kind"],"additionalProperties":false},"CreateEstimateRequestCaption":{"type":"object","properties":{"kind":{"type":"string","enum":["caption"],"description":"Price a POST /v1/captions call."},"preset":{"type":"string","enum":["casper","diego","simple","plain","karl","hustle","beans","corpo","boo","shadeplay","capri","lowkey","vinta","ali","slay","kitty","sprout","flex","mint","rizz","vegas","glass","whisper","glide","glide2","fusion","terminal","handwritten","backdrop","backdrop2"],"description":"Which caption style to price. REQUIRED — the dynamic presets cost 2× the basic ones, so there is no default that could be right for both. List them with GET /v1/caption-presets."},"jobId":{"type":"string","minLength":1,"description":"The video this quote is for: a jobId from POST /v1/videos. At most one of jobId and assetId. Naming a source reads the real file and quotes what captioning it will actually cost."},"assetId":{"type":"string","minLength":1,"description":"The video this quote is for: an assetId from POST /v1/uploads. At most one of jobId and assetId. **Name it for anything longer than a minute** — a caption is billed per minute of source, so a quote without a source is the one-minute minimum and a 10-minute upload will cost ten times it."}},"required":["kind","preset"],"additionalProperties":false},"CreateEstimateRequestMusic":{"type":"object","properties":{"kind":{"type":"string","enum":["music"],"description":"Price a POST /v1/music call. The job it prices reports `kind: \"audio\"` when you poll it — this field names the operation, that one names the output."}},"required":["kind"],"additionalProperties":false},"CreateEstimateRequestTranscript":{"type":"object","properties":{"kind":{"type":"string","enum":["transcript"],"description":"Price a POST /v1/transcripts call. The job it prices reports `kind: \"text\"` when you poll it — this field names the operation, that one names the output."},"jobId":{"type":"string","minLength":1,"description":"The video this quote is for: a jobId from POST /v1/videos. At most one of jobId and assetId. Naming a source reads the real file and quotes what transcribing it will actually cost."},"assetId":{"type":"string","minLength":1,"description":"The video this quote is for: an assetId from POST /v1/uploads. At most one of jobId and assetId. **Name it for anything longer than a minute** — a transcript is billed per minute of source, so a quote without a source is the one-minute minimum."}},"required":["kind"],"additionalProperties":false},"CreateEstimateRequestVoiceover":{"type":"object","properties":{"kind":{"type":"string","enum":["voiceover"],"description":"Price a POST /v1/voiceovers call. The job it prices reports `kind: \"audio\"` when you poll it — this field names the operation, that one names the output."},"script":{"type":"string","minLength":1,"maxLength":1000,"description":"The words you intend to speak. REQUIRED, because the price is per 100 characters of it — and it is checked against the same cap the generation enforces, so a script this endpoint quotes is a script that endpoint accepts."}},"required":["kind","script"],"additionalProperties":false},"CreateEstimateRequestCompetitorAds":{"type":"object","properties":{"kind":{"type":"string","enum":["competitor-ads"],"description":"Price a POST /v1/competitor-ads call. The fee is flat per sweep, so this arm takes nothing else — asking for fewer ads does not cost less. The job it prices reports `kind: \"text\"` when you poll it: this field names the operation, that one names the output."}},"required":["kind"],"additionalProperties":false},"CreateEstimateRequestAnalysis":{"type":"object","properties":{"kind":{"type":"string","enum":["analysis"],"description":"Price a POST /v1/analyses call. The fee is flat per analysis, so this arm takes nothing else — a still, a 6-second ad and a 2-minute one all cost the same, and `maxSeconds` does not move it. The row it prices reports `kind: \"text\"` when you poll it: this field names the operation, that one names the output."}},"required":["kind"],"additionalProperties":false},"TranscriptResponse":{"type":"object","properties":{"jobId":{"type":"string","description":"This transcript's job. Addressable afterwards at GET /v1/generations/{jobId}, whose `outputUrl` presigns this same JSON."},"status":{"type":"string","enum":["succeeded"],"description":"Always succeeded: the transcript is in this response, not polled for."},"creditsCharged":{"type":"number","description":"What this call cost, in credits. ZERO when the transcript was already stored — a repeat of the same source is served from storage and is not billed again."},"model":{"type":"string","enum":["transcript-v1"],"description":"The transcription model."},"language":{"type":"string","description":"The language the transcriber detected or was told, as it reports it — an ISO-639-**3** code such as \"spa\" or \"eng\", passed through verbatim rather than mapped. NOTE THE MISMATCH: `languageCode` on the request is ISO-639-**1** (two letters, \"es\"), so this field will NOT equal what you sent. Do not compare them for equality — send \"es\" and read \"spa\". Passing the vendor's answer through beats inventing a mapping that would be wrong for the first language nobody tested."},"durationSeconds":{"type":"number","description":"Length of the source that was transcribed."},"text":{"type":"string","description":"The whole transcript as one string."},"words":{"type":"array","items":{"$ref":"#/components/schemas/TranscriptWord"},"description":"Every word with its own timing. Times are SECONDS."},"segments":{"type":"array","items":{"$ref":"#/components/schemas/TranscriptSegment"},"description":"The same transcript grouped into sentences. Times are SECONDS."},"srt":{"type":"string","description":"The same transcript as a SubRip subtitle file."}},"required":["jobId","status","creditsCharged","model","language","durationSeconds","text","words","segments","srt"]},"TranscriptWord":{"type":"object","properties":{"text":{"type":"string","description":"The word, as spoken."},"start":{"type":"number","description":"Seconds from the start of the source. NOT milliseconds."},"end":{"type":"number","description":"Seconds from the start of the source. NOT milliseconds."}},"required":["text","start","end"]},"TranscriptSegment":{"type":"object","properties":{"start":{"type":"number","description":"Seconds from the start of the source. NOT milliseconds."},"end":{"type":"number","description":"Seconds from the start of the source. NOT milliseconds."},"text":{"type":"string","description":"The sentence, rebuilt from its words."}},"required":["start","end","text"]},"CreateTranscriptRequest":{"type":"object","properties":{"jobId":{"type":"string","minLength":1,"description":"Transcribe a video this API generated: the jobId returned by POST /v1/videos. Exactly one of jobId and assetId."},"assetId":{"type":"string","minLength":1,"description":"Transcribe a video you uploaded: the assetId returned by POST /v1/uploads. Must be a VIDEO — POST /v1/uploads accepts no audio-only types. Exactly one of jobId and assetId."},"languageCode":{"type":"string","enum":["en","es","pt","fr","de","it","zh","ja","ko","ar","hi"],"description":"Force the spoken language instead of detecting it, as an ISO-639-1 two-letter code. OMIT THIS to auto-detect, which is the right default for almost every caller — the transcriber identifies the language from the audio. Setting it wrong does not fail; it produces a confidently wrong transcript. The `language` field in the response answers in ISO-639-3 (three letters) and will not equal what you send here. (Note the dashboard's own whisper path defaults to forcing English; this endpoint does the opposite, so do not assume parity.)"}},"additionalProperties":false},"CompetitorAdsResponse":{"type":"object","properties":{"ads":{"type":"array","items":{"$ref":"#/components/schemas/CompetitorAd"},"description":"The ads, sorted by `collationCount` descending and then by recency, truncated to `count`. **CAN BE EMPTY on a successful call**, and the sweep is still charged: the vendor bills for the search, not for the finding. An empty list means this brand is not running ads matching your filters right now."},"query":{"type":"string","description":"The query this sweep ran, echoed back."},"mediaType":{"type":"string","enum":["video","image","all"],"description":"The media filter this sweep ran with."},"country":{"type":"string","description":"The country filter this sweep ran with."},"urlsExpire":{"type":"boolean","enum":[true],"description":"Always `true`, and it is here rather than only in the docs because it changes what you must do next. Every media URL above is Meta's own, token-bound and short-lived — minutes to hours. **Download the files now.** `adLibraryUrl` is the one link that keeps working."},"creditsCharged":{"type":"number","description":"What this sweep cost, in credits. FLAT per sweep: the same whether it returned twenty ads, one, or none, and the same whatever `count` you asked for."}},"required":["ads","query","mediaType","country","urlsExpire","creditsCharged"]},"CompetitorAd":{"type":"object","properties":{"adArchiveId":{"type":"string","description":"Meta's own id for this ad. Stable and durable — keep it if you intend to import the creative later, or to recognise the same ad on a future sweep."},"pageName":{"type":"string","description":"The page running the ad, which is NOT always the brand you searched for. Affiliates and resellers run a brand's creatives under their own pages, and those results are kept rather than filtered — see the endpoint description."},"pageId":{"type":"string","description":"Meta's id for that page."},"adLibraryUrl":{"type":"string","description":"Deep link to this ad in Meta's public Ad Library. The ONE durable URL in this response: it does not expire, so store it when the media links do."},"bodyText":{"type":["string","null"],"description":"The ad's primary text, verbatim. `null` when the creative carries none."},"collationCount":{"type":["number","null"],"description":"How many near-identical variants Meta has collated under this creative — the closest thing to a spend signal the Ad Library exposes, and what this list is sorted by. An advertiser running one creative across twelve audiences appears once, with a count of twelve. `null` when Meta reported none."},"startDate":{"type":["string","null"],"description":"When the ad started running, as `YYYY-MM-DD`. `null` when Meta reported none."},"endDate":{"type":["string","null"],"description":"When it stopped, as `YYYY-MM-DD`. `null` for an ad with no scheduled end."},"isActive":{"type":"boolean","description":"Whether Meta still reports this ad as running."},"platforms":{"type":"array","items":{"type":"string"},"description":"Where it runs, verbatim from Meta — e.g. `[\"FACEBOOK\", \"INSTAGRAM\"]`."},"media":{"$ref":"#/components/schemas/CompetitorAdMedia"}},"required":["adArchiveId","pageName","pageId","adLibraryUrl","bodyText","collationCount","startDate","endDate","isActive","platforms","media"]},"CompetitorAdMedia":{"type":"object","properties":{"kind":{"type":"string","enum":["video","image"],"description":"Which of the URL fields below are populated. Branch on this, not on presence."},"videoHdUrl":{"type":"string","description":"The full-resolution MP4, when this is a video ad. **Expires — download it now.**"},"videoSdUrl":{"type":"string","description":"A smaller MP4 of the same creative, when Meta published one. Expires too."},"previewImageUrl":{"type":"string","description":"The video's poster frame. Useful as a thumbnail; also expires."},"imageUrl":{"type":"string","description":"The still, when this is an image ad. **Expires — download it now.**"}},"required":["kind"]},"CreateCompetitorAdsRequest":{"type":"object","properties":{"query":{"type":"string","minLength":2,"maxLength":200,"description":"The brand to sweep for — a name (\"Arcads\") or a domain (\"arcads.ai\"). Matched as an unordered keyword search against Meta's Ad Library, so third-party pages running the brand's creatives are returned too. That is signal, not noise, and it is deliberately not filtered server-side."},"mediaType":{"type":"string","enum":["video","image","all"],"description":"Which creatives to return. REQUIRED — there is no default, because video ads and image ads answer different questions and guessing spends the fee on the wrong one."},"country":{"type":"string","pattern":"^([A-Z]{2}|ALL)$","default":"ALL","description":"ISO 3166-1 alpha-2 country filter, or \"ALL\" for every market. Defaults to \"ALL\"."},"count":{"type":"integer","minimum":1,"maximum":20,"default":20,"description":"How many ads to return, 1..20. Defaults to 20. The fee is FLAT and prices the 20-ad case, so asking for fewer costs the same — ask for fewer only when you want a shorter answer."}},"required":["query","mediaType"],"additionalProperties":false,"example":{"query":"arcads.ai","mediaType":"video","country":"ALL","count":20}},"AnalysisResponse":{"type":"object","properties":{"jobId":{"type":"string","description":"The generation record this analysis charged against. `GET /v1/generations/{jobId}` is the receipt — useful if a call timed out and you need to know whether it ran. It has no `outputUrl`: nothing is stored, so this response is the only copy."},"creditsCharged":{"type":"number","description":"What this analysis cost, in credits. FLAT per call: the same for a still, a 6-second ad and a 2-minute one, and `maxSeconds` does not move it."},"mediaType":{"type":"string","enum":["video","image"],"description":"What the stored asset turned out to be."},"analyzedWindowSeconds":{"type":"number","description":"Seconds of the video the analyser was allowed to read — the ceiling that was sent, not the clip's length. A shorter clip is read in full. Absent for a still."},"summary":{"type":"string","description":"One sentence on what this ad is doing."},"hook":{"type":"object","properties":{"endsAtSeconds":{"type":"number","description":"Where the hook stops."},"signal":{"type":"string","description":"The OBSERVABLE thing that marks the boundary: a cut, a claim landing, a product reveal."}},"required":["endsAtSeconds","signal"],"description":"Where the opening stops earning attention, and the evidence for it. **Absent when the analyser could not point at a boundary** — that is a deliberate refusal to guess, not a missing field."},"beats":{"type":"array","items":{"type":"object","properties":{"atSeconds":{"type":"number"},"description":{"type":"string"}},"required":["atSeconds","description"]},"description":"Beat-by-beat, in order. Empty for a still image."},"onScreenText":{"type":"array","items":{"type":"string"},"description":"Every piece of on-screen text, in reading order."},"casting":{"type":"string","description":"Who is on camera, how they are framed and lit."},"zones":{"type":"array","items":{"$ref":"#/components/schemas/AnalysisZone"},"description":"The frame's layers, top to bottom. A single full-frame shot comes back as one zone."}},"required":["jobId","creditsCharged","mediaType","summary","beats","onScreenText","zones"]},"AnalysisZone":{"type":"object","properties":{"region":{"type":"string","description":"Where in the frame, in the analyser's own words — e.g. `top 12%`, `left gutter`."},"content":{"type":"string","description":"What occupies it."},"sourceType":{"type":"string","enum":["STATIC_BACKGROUND","TEXT_OVERLAY","GENERATED_VIDEO","SCREEN_RECORDING"],"description":"Where this zone's pixels come from. Only `GENERATED_VIDEO` is AI-generated footage; overlays, backgrounds and screen recordings are assembled in post, so prompting one costs a render and returns worse material than the original. A webcam picture-in-picture of a real person is `SCREEN_RECORDING`."},"carriesBrand":{"type":"boolean","description":"Whether this zone shows a brand's name, logo, product or interface. ORTHOGONAL to `sourceType` — every combination is real, and a clone needs both answers: one decides what to generate, the other decides what to replace."}},"required":["region","content","sourceType","carriesBrand"]},"CreateAnalysisRequest":{"type":"object","properties":{"assetId":{"type":"string","minLength":1,"description":"The asset to read, from POST /v1/uploads. A video or a still image — the analyser decides which from the stored file, and the response says which it found."},"maxSeconds":{"type":"integer","minimum":1,"maximum":120,"description":"How many seconds of a VIDEO to read, 1..120. Defaults to 20, which is the hook. Raise it when you want the offer, the CTA or the ending — but the fee is FLAT either way, so a wider window buys more of the ad rather than a cheaper read, and it measurably costs you precision on `hook.endsAtSeconds`. Accepted and ignored on a still image."},"question":{"type":"string","minLength":1,"maxLength":500,"description":"Optional focus for the read, e.g. \"the hook\" or \"the text zones\". An instruction, not content — it steers what the breakdown emphasises and never changes the price."}},"required":["assetId"],"additionalProperties":false,"example":{"assetId":"mcp-uploads/org_123/8f3c-….mp4"}},"CaptionJob":{"type":"object","properties":{"jobId":{"type":"string","description":"Poll it at GET /v1/generations/{jobId}."},"status":{"$ref":"#/components/schemas/GenerationStatus"},"creditsCharged":{"type":"number","description":"Credits debited for this call, in the same unit shown on the billing page (1 credit = 10 centi-credits internally). Refunded automatically if the provider fails after the charge."},"model":{"type":"string","enum":["captions-v1"],"description":"What burns the subtitles in. Fixed — this endpoint has one renderer, and it is not a model `GET /v1/models` offers, because a caption is applied to a video rather than generating one."}},"required":["jobId","status","creditsCharged","model"]},"CreateCaptionsRequest":{"type":"object","properties":{"preset":{"type":"string","enum":["casper","diego","simple","plain","karl","hustle","beans","corpo","boo","shadeplay","capri","lowkey","vinta","ali","slay","kitty","sprout","flex","mint","rizz","vegas","glass","whisper","glide","glide2","fusion","terminal","handwritten","backdrop","backdrop2"],"description":"Which caption style to burn in. REQUIRED — there is no default. Price it first with POST /v1/estimates `{ kind: \"caption\", jobId }`; list the styles with GET /v1/caption-presets."}},"required":["preset"],"additionalProperties":false},"CreateCaptionJobRequest":{"type":"object","properties":{"jobId":{"type":"string","minLength":1,"description":"Caption a video this API generated: the jobId returned by POST /v1/videos. Exactly one of jobId and assetId."},"assetId":{"type":"string","minLength":1,"description":"Caption a video you uploaded: the assetId returned by POST /v1/uploads. Must be a video, not a still. Exactly one of jobId and assetId."},"preset":{"type":"string","enum":["casper","diego","simple","plain","karl","hustle","beans","corpo","boo","shadeplay","capri","lowkey","vinta","ali","slay","kitty","sprout","flex","mint","rizz","vegas","glass","whisper","glide","glide2","fusion","terminal","handwritten","backdrop","backdrop2"],"description":"Which caption style to burn in. REQUIRED — there is no default. Price it first with POST /v1/estimates; list the styles with GET /v1/caption-presets."}},"required":["preset"],"additionalProperties":false},"CaptionPresetList":{"type":"object","properties":{"presets":{"type":"array","items":{"$ref":"#/components/schemas/CaptionPreset"}}},"required":["presets"]},"CaptionPreset":{"type":"object","properties":{"id":{"type":"string","description":"The value to send as `preset`."},"tier":{"type":"string","enum":["basic","dynamic"],"description":"Which price schedule this style is on. `dynamic` styles are context-aware and animated, and cost 2× the basic ones."},"credits":{"type":"number","description":"The credit RATE for this style, per billed minute of video. A video of a minute or less costs exactly this. Longer sources multiply it by their whole minutes, rounded up, and anything above the 1080p tier doubles it again — so a 10-minute 4K source at a basic style costs 20x the figure here. Resolution is measured on the SHORT edge, so an ordinary portrait 1080x1920 clip is NOT above 1080p."}},"required":["id","tier","credits"]},"GenerationList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/GenerationListItem"}},"total":{"type":"integer","description":"Jobs matching the filters, ignoring limit and offset."},"limit":{"type":"integer"},"offset":{"type":"integer"},"hasMore":{"type":"boolean"}},"required":["items","total","limit","offset","hasMore"]},"GenerationListItem":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"$ref":"#/components/schemas/GenerationStatus"},"kind":{"type":["string","null"]},"model":{"type":"string"},"creditsCharged":{"type":"number","description":"Credits debited for this call, in the same unit shown on the billing page (1 credit = 10 centi-credits internally). Refunded automatically if the provider fails after the charge."},"createdAt":{"type":"string","description":"ISO 8601."},"updatedAt":{"type":"string","description":"ISO 8601. The cursor for `updatedSince`."},"promptPreview":{"type":"string","description":"First 120 characters of the prompt."},"outputUrl":{"type":"string","format":"uri","description":"Present only on a succeeded job."},"outputUrlExpiresInSeconds":{"type":"integer"}},"required":["jobId","status","kind","model","creditsCharged","createdAt","updatedAt","promptPreview"]},"Generation":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"$ref":"#/components/schemas/GenerationStatus"},"kind":{"type":"string"},"model":{"type":"string"},"prompt":{"type":"string"},"createdAt":{"type":"string","description":"ISO 8601."},"outputUrl":{"type":"string","format":"uri","description":"Presigned download URL. Present only once the job has succeeded."},"outputUrlExpiresInSeconds":{"type":"integer","description":"How long `outputUrl` stays valid (3600 seconds). Read the job again for a fresh one."},"error":{"type":"string","description":"Present only on a failed job. Always our own wording, never a provider's raw text. Credits for a failed job are refunded."},"audio":{"type":"array","items":{"$ref":"#/components/schemas/AudioTrack"},"description":"The tracks a music job delivered. Present only on `kind: \"audio\"`, and only once it has succeeded.\n\n**Two entries, one job, one charge.** The provider renders two takes of the same prompt per request; both are returned and both are yours. They differ in length and arrangement, not in price.\n\n**`audio[0]` is the canonical output** — it is what `outputUrl` points at and what appears in the dashboard library. **`audio[1]` is a variant of the same job**: identical file guarantees, identical URL lifetime, but it is **not** a separate library asset. This field is the only place it is published, so a caller who assumes symmetry and goes looking for the second track elsewhere will not find it.\n\n**Every URL here is presigned and minted at read time.** Re-poll for fresh ones; do not store them."}},"required":["jobId","status","kind","model","prompt","createdAt"]},"AudioTrack":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Presigned download URL."},"expiresInSeconds":{"type":"integer","description":"How long `url` stays valid (3600 seconds). Read the job again for a fresh one."},"durationSeconds":{"type":"number","description":"Length of this track, as the provider reported it."},"title":{"type":"string","description":"The provider's own title for the take."}},"required":["url","expiresInSeconds"]},"ProductList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Product"}},"total":{"type":"integer","description":"Products matching the filters, ignoring limit and offset."},"limit":{"type":"integer"},"offset":{"type":"integer"},"hasMore":{"type":"boolean"}},"required":["items","total","limit","offset","hasMore"]},"Product":{"type":"object","properties":{"id":{"type":"string","description":"Pass this as `productId` when generating."},"name":{"type":"string"},"description":{"type":["string","null"]},"targetAudience":{"type":["string","null"]},"mainFeatures":{"type":["array","null"],"items":{"type":"string"}},"painPoint":{"type":["string","null"]},"perceived":{"type":["string","null"]},"folders":{"type":"array","items":{"type":"string"},"description":"Ids of the folders under this product. See GET /v1/products/{productId}/folders."},"createdAt":{"type":"string","description":"ISO 8601."},"updatedAt":{"type":"string","description":"ISO 8601. The cursor for `updatedSince`."}},"required":["id","name","description","targetAudience","mainFeatures","painPoint","perceived","folders","createdAt","updatedAt"]},"CreateProductRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What you call this product. Up to 200 characters.","example":"Aurora Sleep Tea"},"description":{"type":"string","maxLength":2000,"description":"What the product is. Up to 2000 characters. Stored and returned as you send it; it does not influence what is generated.","example":"A caffeine-free herbal tea blended for people who fall asleep late."},"targetAudience":{"type":"string","maxLength":500,"description":"Who it is for. Stored and returned as you send it; it does not influence what is generated.","example":"Adults 25-45 who work late and struggle to wind down."},"mainFeatures":{"type":"array","items":{"type":"string","maxLength":200},"maxItems":20,"description":"What it does, one item per feature. Up to 20 items of 200 characters. Stored and returned as you send it; it does not influence what is generated.","example":["Caffeine free","Valerian and chamomile","Brews in 3 minutes"]},"painPoint":{"type":"string","maxLength":500,"description":"The problem it solves. Stored and returned as you send it; it does not influence what is generated.","example":"Lying awake for an hour after getting into bed."},"perceived":{"type":"string","maxLength":500,"description":"Perceived value — what a buyer feels it is worth. Stored and returned as you send it; it does not influence what is generated.","example":"A nightly ritual worth more than the price of a coffee."}},"required":["name"],"additionalProperties":false},"UpdateProductRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What you call this product. Up to 200 characters.","example":"Aurora Sleep Tea"},"description":{"type":"string","maxLength":2000,"description":"What the product is. Up to 2000 characters. Stored and returned as you send it; it does not influence what is generated.","example":"A caffeine-free herbal tea blended for people who fall asleep late."},"targetAudience":{"type":"string","maxLength":500,"description":"Who it is for. Stored and returned as you send it; it does not influence what is generated.","example":"Adults 25-45 who work late and struggle to wind down."},"mainFeatures":{"type":"array","items":{"type":"string","maxLength":200},"maxItems":20,"description":"What it does, one item per feature. Up to 20 items of 200 characters. Stored and returned as you send it; it does not influence what is generated.","example":["Caffeine free","Valerian and chamomile","Brews in 3 minutes"]},"painPoint":{"type":"string","maxLength":500,"description":"The problem it solves. Stored and returned as you send it; it does not influence what is generated.","example":"Lying awake for an hour after getting into bed."},"perceived":{"type":"string","maxLength":500,"description":"Perceived value — what a buyer feels it is worth. Stored and returned as you send it; it does not influence what is generated.","example":"A nightly ritual worth more than the price of a coffee."}},"additionalProperties":false},"FolderList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Folder"}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"hasMore":{"type":"boolean"}},"required":["items","total","limit","offset","hasMore"]},"Folder":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"productId":{"type":"string"},"createdAt":{"type":"string","description":"ISO 8601."},"updatedAt":{"type":"string","description":"ISO 8601."}},"required":["id","name","productId","createdAt","updatedAt"]},"Models":{"type":"object","properties":{"models":{"type":"array","items":{"$ref":"#/components/schemas/Model"}}},"required":["models"]},"Model":{"type":"object","properties":{"id":{"type":"string","description":"Value reported as `model` on a job."},"displayName":{"type":"string"},"kind":{"type":"string","enum":["video","image"]},"endpoint":{"type":"string","description":"Which endpoint generates with this model."},"credits":{"type":"number","description":"Cost of one representative output, in credits."},"representativeOutput":{"type":"string","description":"What `credits` prices — the duration or unit it refers to."},"aspectRatios":{"type":"array","items":{"type":"string"}},"durationsSeconds":{"type":"array","items":{"type":"integer"},"description":"Video only. Every accepted length."},"maxPromptCharacters":{"type":"integer"}},"required":["id","displayName","kind","endpoint","credits","representativeOutput","aspectRatios","maxPromptCharacters"]}},"parameters":{}},"paths":{"/uploads":{"post":{"operationId":"createUpload","tags":["Uploads"],"summary":"Get a presigned URL for a file","description":"Mints a presigned `PUT` for an image or video and returns the `assetId` that identifies it in later calls. Upload the raw bytes yourself, then reference the `assetId`.\n\nThe storage key is generated server-side and namespaced to your organization — you never propose one — which is why an `assetId` cannot be pointed at anybody else's file.","requestBody":{"description":"The file you are about to upload.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUploadRequest"}}}},"responses":{"201":{"description":"Presigned upload, and the id to use afterwards.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Upload"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/videos":{"post":{"operationId":"createVideo","tags":["Videos"],"summary":"Generate a video","x-codeSamples":[{"lang":"shell","label":"Full flow (bash)","source":"export NOVOADS_API_KEY=\"novo_…\"\nexport NOVOADS_API=\"https://api.novoads.ai/v1\"\n\n# 1. What will this cost? Spends nothing. Also lints the prompt and returns\n#    craft advice in `warnings` — free to read, and safe to ignore.\ncurl -s \"$NOVOADS_API/estimates\" \\\n  -H \"Authorization: Bearer $NOVOADS_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"kind\": \"video\",\n    \"durationSeconds\": 5,\n    \"prompt\": \"A woman holds up the bottle: \\\"I stopped buying the other one.\\\"\"\n  }'\n\n# 2. Submit. Returns immediately with a jobId; the video is NOT ready yet.\n#    `model` is optional and defaults to seedance-2.0. Each model has its\n#    own accepted durations and aspect ratios — see GET /models.\nJOB=$(curl -s \"$NOVOADS_API/videos\" \\\n  -H \"Authorization: Bearer $NOVOADS_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"model\": \"seedance-2.0\",\n    \"aspectRatio\": \"9:16\",\n    \"durationSeconds\": 5,\n    \"prompt\": \"A woman holds up the bottle: \\\"I stopped buying the other one.\\\"\"\n  }' | jq -r .jobId)\n\n# 3. Poll until TERMINAL, not until succeeded. Typically 4 to 7 minutes.\n#    `queued` means it has not started yet — normal, not a stall.\n#    Waiting only for `succeeded` never returns on a job that failed.\nwhile :; do\n  STATUS=$(curl -s \"$NOVOADS_API/generations/$JOB\" \\\n    -H \"Authorization: Bearer $NOVOADS_API_KEY\" | jq -r .status)\n  case \"$STATUS\" in\n    succeeded) break ;;\n    failed|blocked|canceled) echo \"ended: $STATUS\"; exit 1 ;;\n  esac\n  sleep 10\ndone\n\n# 4. Download. `watch` 302s to a URL signed at request time, so it is never\n#    stale.\ncurl -L -o ad.mp4 \"$NOVOADS_API/generations/$JOB/watch\" \\\n  -H \"Authorization: Bearer $NOVOADS_API_KEY\""}],"description":"Submits a video generation and returns a `jobId` immediately. **The video is not ready when this returns** — poll `GET /generations/{jobId}` until `status` is terminal, or follow `GET /generations/{jobId}/watch` once it is.\n\n**Pick a model with `model`.** The body is one schema per model, so each carries its own typed grid — its own accepted durations, aspect ratios and prompt ceiling — rather than one flat shape with per-model caveats in prose. A generated client refuses an invalid combination before it is sent. `GET /models` lists every model with its grid and price.\n\n`model` is marked `required` in every variant so a generated client always sends it, and sending it is never wrong. The server is looser than the document here: omit `model` entirely and you get `seedance-2.0`, exactly as before this parameter existed.\n\nA duration a model does not render is an **error, not a rounded request**. Providers snap an off-grid length to a neighbouring one, which would mean paying for the length you asked for and receiving the length they chose, so it is refused before anything is charged.\n\n**Two ways to give the model a picture, and they are different modes.** `startImageAssetId` animates one image as the opening frame. `referenceAssetIds` (Seedance) composites several — the character, the product, the wardrobe, the setting — into a scene the prompt describes, addressed positionally as `@Image1`, `@Image2` … in the order you send them. They cannot be combined: sending both is an error rather than a request where one of them is quietly ignored.\n\nReference assets are **images only**, even though `POST /uploads` accepts video. That is a pricing boundary, not an oversight — what you are charged is a function of duration alone, and a video reference is not, so accepting one would break the promise that `POST /estimates` and your invoice agree.\n\nTypical renders finish in about 4 to 7 minutes. A job sitting at `queued` has not started yet; that is normal, not a stall.\n\nPoll until the status is TERMINAL — `succeeded`, `failed`, `blocked` or `canceled` — not until it is `succeeded`. A loop that waits only for success never exits on a job that failed.\n\n**No house style is enforced on your prompt.** Nothing is rejected for wording, structure, a missing actor description or a word we happen to dislike — write the ad you want. The only prompt check that can refuse this call is content moderation, which answers `422`. If you want a second opinion, `POST /estimates` lints for free and returns it as advice that nothing acts on.\n\nCredits are charged at submission. They are refunded automatically if dispatch fails, and if the provider fails the render — in which case the job goes to `failed` on the next poll and the refund lands with it.","requestBody":{"description":"What to generate.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateVideoRequest"}}}},"responses":{"202":{"description":"Accepted, charged, and queued for rendering.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoJob"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Content moderation rejected the prompt. Nothing was charged. This is the ONLY way a prompt is refused for what it says — a `400` here means the request was malformed (an unknown field, a duration this model does not render), never that we disliked the writing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/images":{"post":{"operationId":"createImage","tags":["Images"],"summary":"Generate a static ad image","description":"Generates one to four images and returns them. Unlike video, this runs the provider inline: the call blocks for the render, **typically 60 to 90 seconds**, and comes back with the images already finished.\n\n**Pick a model with `model`.** The body is one schema per model, so each carries its own typed grid — its own accepted aspect ratios, reference-image ceiling and prompt ceiling — rather than one flat shape with per-model caveats in prose. A generated client refuses an invalid combination before it is sent. Omit `model` and you get `gpt-image-2`, at exactly the price it charged before this parameter existed. The models are not priced alike — they differ by more than 3× per image — so price the one you mean with `POST /estimates`, and see `GET /models` for every grid.\n\nAn aspect ratio a model does not render is an **error, not a rounded request**, for the same reason durations are on `/videos`: a collapsed ratio means paying for the shape you asked for and receiving the shape the provider chose.\n\nSet your client timeout to at least 120 seconds — and treat a timeout as *unknown*, not as failed. A render that runs long can be cut by the CDN in front of this API (around 100 seconds) even though the work completes and is charged: the response carrying the `jobId` is what was lost, not the image.\n\n`GET /generations` is the recovery path and the authoritative one. Call it after any timeout on this endpoint, match on `createdAt`, and you will find the job — this is exactly why that endpoint exists.","requestBody":{"description":"What to generate.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateImageRequest"}}}},"responses":{"200":{"description":"The finished images.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageJob"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Content moderation rejected the prompt. Nothing was charged. This is the ONLY way a prompt is refused for what it says — a `400` here means the request was malformed (an unknown field, a duration this model does not render), never that we disliked the writing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/music":{"post":{"operationId":"createMusic","tags":["Music"],"summary":"Generate a music bed","description":"Submits a music generation and returns a `jobId` immediately. **The track is not ready when this returns** — poll `GET /generations/{jobId}` until `status` is terminal, exactly as for `POST /videos`. Typical renders finish in about 75 seconds.\n\n**One request returns TWO tracks, for one charge.** The model renders two takes of the same prompt; both come back in `audio[]` on the polled job and both are yours. They differ in length and arrangement, not in price. `audio[0]` is the canonical output — it is what `outputUrl` points at and what appears in the dashboard library. `audio[1]` is a variant of the same job: the same file guarantees and the same URL lifetime, but it is **not** a separate library asset. `audio[]` on the polled job is the only place it is published.\n\n**Every URL in `audio[]` is presigned and minted when you read the job.** Re-poll for fresh ones rather than storing them.\n\n**Only `prompt` reaches the model.** `style` and `durationHintSeconds` are composed into the prompt text on our side — the provider exposes neither as a parameter in the mode this endpoint uses. `durationHintSeconds` in particular is a preference, not a setting: expect roughly one to two minutes of audio whatever you ask for, and trim it yourself. Neither field moves the price.\n\n**The price is flat per request** and has no duration axis, because the provider's own bill has none either. Price it with `POST /estimates` `{ \"kind\": \"music\" }` — note that arm is `music` while the job reports `kind: \"audio\"` when you poll it: one names the operation, the other names the output.\n\nMusic counts against the same concurrency ceiling as `POST /videos`, because it is the same shared provider budget.\n\nCredits are charged at submission and refunded automatically if dispatch fails or the provider fails the render.","requestBody":{"description":"What the track should sound like.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMusicRequest"}}}},"responses":{"202":{"description":"Accepted, charged, and queued for rendering.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MusicJob"}}}},"400":{"description":"The request failed validation, **or music generation is not enabled on this deployment** — the message names what this deployment does render. `error.details.issues` names each bad field when it is the former.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Content moderation rejected the prompt. Nothing was charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/voiceovers":{"post":{"operationId":"createVoiceover","tags":["Voice"],"summary":"Generate a voice-over","description":"Renders a line of text as speech and returns **the finished audio** — `200`, not `202`.\n\nThis is the one generating endpoint on this API that does not hand back a job to poll. The provider answers in seconds and returns bytes rather than a job id, so there is no queue to wait on. The `jobId` in the response exists for the other two reasons a job id exists: reconciling spend, and finding this call again in `GET /generations` when **this response** is what times out. If that happens, do **not** call again — the audio was probably rendered and charged. `GET /generations?kind=audio` will show it.\n\n**`voiceId` is required and there is no default.** A default voice is a performance you would be charged for without hearing it, and it would change under you whenever the catalog is re-synced. Get one from `GET /voices`.\n\n**The script is read verbatim**, including anything in square brackets — the model interprets those as performance tags rather than skipping them, so keep stage directions out of it.\n\n**Priced per 100 characters of the trimmed script, rounded up**, with a 1 centi-credit minimum. It is the same schedule the dashboard bills for the same render. Price it with `POST /estimates` `{ \"kind\": \"voiceover\", \"script\": \"…\" }` — note that arm is `voiceover` while the job reports `kind: \"audio\"` when you poll it: one names the operation, the other names the output.\n\nVoice-overs have their OWN concurrency ceiling, separate from `POST /videos`. That is deliberate: the flow this exists for narrates the gaps between clips it is still rendering, so a voice-over must never consume a slot the next clip needs. A `429` here carries `error.details.reason: \"voiceover_concurrency_limit\"`.\n\nCredits are charged before the provider call and refunded automatically if it fails.","requestBody":{"description":"What to say, and who says it.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateVoiceoverRequest"}}}},"responses":{"200":{"description":"Rendered, charged, and ready to download.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Voiceover"}}}},"400":{"description":"The request failed validation, **or voice-over generation is not enabled on this deployment** — the message names what this deployment does render. A script over the character cap and a language the chosen voice does not speak are both here, both named, and both refused before anything is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such voice, or one this organization may not use. The two are deliberately indistinguishable — telling them apart would let a caller enumerate other organizations' cloned voices.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Content moderation rejected the script. Nothing was charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/voices":{"get":{"operationId":"listVoices","tags":["Voice"],"summary":"List voices","description":"The voices `POST /voiceovers` will speak in: the platform voices plus **your own** organization's cloned and designed ones. Another organization's clones are never listed, and asking for one by id is a `404`.\n\nReads only. Spends nothing, and unpaginated — this is a list to pick from, not a feed.\n\n`languages` is present where we have recorded what a voice speaks, and absent otherwise, which is normal for a cloned voice. A voice with no recorded languages accepts any `language` on `POST /voiceovers`; a voice with them refuses one that is not on the list, before anything is charged.","responses":{"200":{"description":"Every voice available to this organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoiceList"}}}},"400":{"description":"Voice-over generation is not enabled on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/estimates":{"post":{"operationId":"createEstimate","tags":["Reference"],"summary":"Price a generation without running it","description":"Returns what a `/videos`, `/images`, `/captions`, `/music`, `/transcripts`, `/competitor-ads` or `/analyses` call would cost, your current balance, and whether the balance covers it. Spends nothing and creates no job.\n\nThe request mirrors the generation request. Price a chain of calls with this before firing it.\n\nThe `caption` arm takes no `prompt` — a caption is priced from its style alone, because the video it applies to already exists and the subtitle text comes from that video's own audio.\n\nThe `transcript` arm takes no `prompt` either, for the same reason, and is priced per minute of the source — so **name the source** (`jobId` or `assetId`) for anything longer than a minute, or the quote is the one-minute minimum. It prices the OPERATION: the job it prices reports `kind: \"text\"` when you poll it.\n\nThe `music` arm takes nothing but the kind: the price is flat per request, there is no duration axis, and there is no prompt to quote against. It prices the OPERATION: the job it prices reports `kind: \"audio\"`.\n\nThe `competitor-ads` arm takes nothing but the kind either, and for a stronger reason than music: the fee is flat per search, so neither the brand you name, the media type nor the number of ads you ask for can move it. It prices the OPERATION: the row it prices reports `kind: \"text\"`.\n\nThe `analysis` arm is the same shape again: flat per call, so neither the asset nor `maxSeconds` can move it — which makes it the one quote you can ask for before you have uploaded anything. It prices the OPERATION: the row it prices reports `kind: \"text\"`.\n\nIt is also the one endpoint that lints your prompt. `warnings` carries what our own craft rules — each measured on a render that came back wrong — noticed about it. **All of it is advice**: no finding here refuses a generation, none of them changes the price, and the generation endpoints do not run these rules at all. Read them or ignore them.\n\n`sufficient` is a snapshot, not a reservation: the balance can change before you generate, and the debit at generation time stays authoritative.","requestBody":{"description":"The generation you are pricing.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEstimateRequest"}}}},"responses":{"200":{"description":"The quote and your balance.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Estimate"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Content moderation rejected the prompt. Craft findings are never an error — they come back in `warnings` on a 200.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/transcripts":{"post":{"operationId":"createTranscript","tags":["Transcripts"],"summary":"Get the words of a video, with their timings","description":"Returns the spoken words of a video, **in this response**. There is no `jobId` to poll — the transcription runs inline and the transcript is in the `200` body. The job is still addressable afterwards at `GET /generations/{jobId}`, whose `outputUrl` presigns this same JSON.\n\n**Three renderings of ONE transcription come back together, and they cannot disagree.** `words[]` carries every word with its own start and end, `segments[]` groups the same text into sentences, and `srt` is a SubRip file. They are three views of a single call rather than three calls, so there is no granularity option to set: choosing one would change nothing about the bill and only remove fields from the response.\n\n**All timings are in SECONDS**, as decimal numbers — `0.42`, not `420`. This is worth reading twice if you have used the dashboard's caption pipeline, which reports the same concept in milliseconds.\n\n**The source is either provenance.** Pass `jobId` for a video this API generated, or `assetId` for one you uploaded with `POST /uploads`. Exactly one — sending both is a `400` rather than a guess. An `assetId` must name a **video**: `POST /uploads` accepts no audio-only types.\n\n**Priced per minute of source audio**: `0.1 credits x whole minutes, rounded up, minimum one`, so a 15-second ad and a 55-second one both cost 0.1 credits and a 10-minute source costs 1.0. There is no resolution term — unlike `POST /captions` — because the transcriber is billed on duration and never sees the picture. Price any source before you send it with `POST /estimates`.\n\n**Sources are capped at 10 minutes**, because the response is returned inline rather than polled. A longer one answers `400` naming the limit and **charges nothing**.\n\n**A source with no audio is refused, not charged.** A video rendered with `audioEnabled: false`, or an upload with no audio track, answers `409` — transcribing it would produce an empty result you paid for. B-roll clips are exactly this shape. A source that HAS an audio track but no speech in it is discovered one step later and answers `409` too, with the credits refunded.\n\n**A caption job's `jobId` is not a transcribable source.** `POST /captions` produces a video with the words burned into the picture, and its words are the ORIGINAL video's words — so pass the original's `jobId`. A caption job's id answers `404`, identically to an id that does not exist.\n\n**Transcribing the same source twice is free.** The second call returns the stored transcript with `creditsCharged: 0` — so a client that times out and retries is not billed twice, and two skills that each want the same words pay once between them. Forcing a different `languageCode` is a different transcript, not a cache hit. Re-uploading different bytes under the same `assetId` is also not a cache hit: the identity covers the stored object's size, not just its name.\n\n**Omitting `languageCode` auto-detects**, which is the right default for almost every caller. `language` in the response reports what the transcriber decided, **verbatim, as an ISO-639-3 code** such as `spa` or `eng` — a THREE-letter code. It will not equal the two-letter `languageCode` you sent (send `es`, read `spa`); do not compare the two for equality.\n\nTranscripts have their own concurrency ceiling, counted separately from the render budget so transcribing a batch can never block your next generation. Hitting it answers `429` with `error.details.reason` = `transcript_concurrency_limit`.","requestBody":{"description":"The source, and optionally its language.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTranscriptRequest"}}}},"responses":{"200":{"description":"The transcript, and what it cost.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TranscriptResponse"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such job or asset for this organization, or the upload was never completed — **or the `jobId` names a caption job**, whose burned-in copy is not a transcribable source. Transcribe the original video instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The source job has not succeeded yet, it has no audio to transcribe, no speech was detected in it, **or a transcript of this source is already in flight** — the message names that job id, so poll it or retry in a moment and the stored transcript comes back without a second charge. Anything charged before a `409` is refunded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/competitor-ads":{"post":{"operationId":"searchCompetitorAds","tags":["Competitor ads"],"summary":"Find the ads a brand is running right now","description":"Searches Meta's public Ad Library for a brand's live ads and returns them **in this response** — the text, how many variants of each are running, and links to the creative itself. There is no `jobId` to poll: the search runs inline and typically answers in about ten seconds.\n\n**Download the media immediately.** Every media URL here is Meta's own, token-bound and short-lived — minutes to hours, not days. `urlsExpire` is `true` in every response to say so where code can see it. The one durable link is `adLibraryUrl`, which never expires; store that beside anything you keep. A response filed away for tomorrow is a list of dead links.\n\n**The fee is FLAT: 0.2 credits per search**, whatever comes back. Twenty ads, one ad, or none cost the same, and lowering `count` buys a shorter answer rather than a cheaper one. Price it with `POST /estimates` `{ kind: \"competitor-ads\" }` — the same expression this endpoint charges with.\n\n**Zero ads is a successful, charged answer.** `ads: []` with a `200` means this brand is not running ads matching your filters right now, which is a finding worth having and is exactly what you paid the search to determine. It is deliberately not an error: an empty result and a broken vendor must not look the same to your code, so a vendor failure answers `502` and refunds instead.\n\n**Ads whose creative cannot be downloaded are left out of `ads`.** Some formats — carousels and dynamic creative in particular — are published without a video or image file on the ad itself, and an entry with no URL would be a row of metadata about something you cannot see. A search where every match is one of those returns `ads: []` and is charged like any other answer: the vendor ran and billed, and \"this brand's live ads have nothing you can fetch\" is the true finding, not a failure.\n\n**`mediaType` is required and has no default.** Video ads and image ads answer different questions — one is a script and a performance to study, the other is a layout and a claim — and guessing would spend the fee on the wrong one. `country` defaults to `ALL` and `count` to 20, the maximum, because the flat fee already prices it.\n\n**Results are not filtered to the brand you named, on purpose.** This is an unordered keyword search, so affiliates, resellers and comparison pages running the brand's creatives come back too. That is signal rather than noise — in our own testing a brand's live spend sat mostly on third-party pages — and deciding which pages count is judgement that belongs to you and your `pageName` filter, not to a server guessing.\n\n**Ranking is mechanical**: `collationCount` descending, then most recent first, then truncated to `count`. `collationCount` is how many near-identical variants Meta has collated under one creative, which is the closest thing to a spend signal the Ad Library publishes. Nothing here judges which ad is *winning*.\n\nDuplicates are removed before ranking, so the same ad reaching the list twice under different ids appears once. Three keys do it: Meta's ad id, Meta's collation id, and — for the ads Meta groups under no collation at all — the opening of the ad's own copy, which is what catches a recurring creative republished under a fresh id.\n\n**There is no idempotency key.** A client timeout on a search that actually succeeded can be re-run into a second charge if you blindly retry, the same stance `POST /videos` takes. Call `GET /generations` first — the search is recorded there as a job you can find by its `prompt`, which is the query you sent. That record is a receipt and nothing more: it carries no `outputUrl`, because the only copy of the ads is the response you already have and its media links are Meta's, expiring.\n\nSearches have their own concurrency ceiling, counted separately from the render budget, so a batch of them can never block your next generation. Hitting it answers `429` with `error.details.reason` = `competitor_ads_concurrency_limit`.","requestBody":{"description":"The brand to search for, and which of its creatives you want.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCompetitorAdsRequest"}}}},"responses":{"200":{"description":"The ads, and what the search cost. `ads` may be empty; the search is charged either way.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompetitorAdsResponse"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"The Ad Library vendor failed, timed out, or returned a shape we could not read. The fee is refunded automatically — this is the case that is deliberately NOT reported as an empty result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/analyses":{"post":{"operationId":"createAnalysis","tags":["Analysis"],"summary":"Read an ad into a structured breakdown","description":"Reads an ad you uploaded — a video or a still — and returns **in this response** what it is doing: a one-line summary, where the hook stops and the observable signal that marks it, the beat-by-beat timeline, every piece of on-screen text, how the subject is cast and framed, and the frame's layers labelled by where their pixels come from. There is no `jobId` to poll for it.\n\nThis is the half of ad cloning a generation endpoint cannot do. A clone built from a description somebody guessed at is a guess; this is the read you build from.\n\n**The fee is FLAT: 1 credit per call**, whatever comes back. A still, a six-second ad and a two-minute one cost the same, and `maxSeconds` buys focus rather than a discount. Price it with `POST /estimates` `{ kind: \"analysis\" }` — the same expression this endpoint charges with.\n\n**`maxSeconds` defaults to 20, and that default is measured rather than cautious.** Twenty seconds is the hook, and it is where this reads best: across a live evaluation of four real ads, `hook.endsAtSeconds` was tight and repeatable at 20 seconds and scattered badly at 120. Raise it when you want the offer, the CTA or the ending — those are real reasons — but raise it knowing it costs you precision on the hook rather than buying you a better read of it. The ceiling is 120 seconds and it is always sent to the model, so a longer ad is trimmed rather than refused.\n\n**A missing `hook` is a finding, not a gap.** The analyser is told to omit a boundary it cannot point at, and a boundary that arrives without its observable signal is dropped before you see it. A number with no evidence behind it is the one output that would quietly ruin a clone, so it is never published.\n\n**`sourceType` and `carriesBrand` on a zone are ORTHOGONAL**, and a clone needs both. `sourceType` decides what to GENERATE — only `GENERATED_VIDEO` zones are AI footage, and prompting an overlay or a screen recording spends a render to get back something worse than the original. `carriesBrand` decides what to REPLACE. Every combination of the two is real.\n\n**Uploads are capped at 18MB** for this endpoint, well under `POST /uploads`' own limit: the file is sent to the model inline. A larger asset answers `400` naming the limit and **charges nothing**. Anything that is neither a video nor an image answers `415`, also free.\n\n**There is no idempotency key.** A client timeout on an analysis that actually succeeded can be re-run into a second charge if you blindly retry — the same stance `POST /videos` and `POST /competitor-ads` take. Call `GET /generations` first: the analysis is recorded there as a job you can find. That record is a receipt and nothing more; it carries no `outputUrl`, because nothing is stored and this response is the only copy.\n\nAnalyses have their own concurrency ceiling, counted separately from the render budget, so reading a batch of ads can never block your next generation. Hitting it answers `429` with `error.details.reason` = `analysis_concurrency_limit`. A separate hourly allowance answers `429` with `analysis_hourly_limit`, which is not a queue — nothing is in flight, so wait out `retryAfterSeconds`.\n\nThe same operation is available as the `analyze_ad` tool on the [MCP connector](https://novoads.ai/mcp), at the same price, for agents that already speak MCP.","requestBody":{"description":"The asset to read, and how much of it.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAnalysisRequest"}}}},"responses":{"200":{"description":"The breakdown, and what it cost.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalysisResponse"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"The asset is neither a video nor an image. Nothing is charged — the media type is checked before the debit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"The analyser failed, timed out, returned nothing readable, or described no ad. The fee is refunded automatically, and the message says whether the refund has already landed or has been queued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/videos/{jobId}/captions":{"post":{"operationId":"createCaptions","tags":["Captions"],"summary":"Burn subtitles into a generated video","description":"Adds styled, burned-in subtitles to a video this API generated, and returns a `jobId` immediately. **The captioned video is not ready when this returns** — poll `GET /generations/{jobId}` and download through `/watch`, exactly as for `POST /videos`. A caption is a job like any other here, with its own row, its own status and its own refund.\n\n**There is nothing to write.** The subtitle text is transcribed from the video's own audio. The only choice is `preset`, the visual style, and it is **required** — the two tiers are priced differently (0.4 credits for `basic`, 0.8 for `dynamic`), so there is no default that could be right for both. `GET /caption-presets` lists them.\n\n**The price scales with the video's length**, on the same meter fal bills us: `rate x whole minutes, rounded up, minimum one` , doubled again above the 1080p tier. A video of a minute or less — which is everything this API generates — costs exactly the tier rate, unchanged from 2.5.0. Resolution is measured on the SHORT edge, so an ordinary portrait `1080x1920` is NOT above 1080p. To caption a video you uploaded rather than one this API generated, use `POST /captions` with an `assetId`.\n\n`jobId` must be a job **this API produced** and that has **succeeded**. A job from the dashboard in the same organization answers `404`, identically to one that does not exist — a distinguishable response would be a way to probe what else the account contains.\n\nA video rendered with `audioEnabled: false` answers `409`: it has no speech to transcribe, and captioning it would produce an empty result you were still charged for.\n\n**Captioning the same video in the same style twice is safe.** The second call returns the FIRST job's id and charges nothing — the constraint is in the database, so two identical requests racing each other still produce one job and one charge. A *different* style on the same video is a new job, not a conflict.\n\nCaptions have their own concurrency ceiling, counted separately from the render budget so a batch of captions can never block your next generation. Hitting it answers `429` with `error.details.reason` = `caption_concurrency_limit`.","parameters":[{"schema":{"type":"string","description":"`jobId` from POST /v1/videos or POST /v1/images.","example":"f2cd403d-8b60-49dd-885c-091b83f1c597"},"required":true,"description":"`jobId` from POST /v1/videos or POST /v1/images.","name":"jobId","in":"path"}],"requestBody":{"description":"Which style to burn in.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCaptionsRequest"}}}},"responses":{"202":{"description":"Accepted, charged, and queued for captioning.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptionJob"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such job for this organization, or it was not produced through this API.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The source job has not succeeded yet, or it was rendered without audio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/captions":{"post":{"operationId":"createCaption","tags":["Captions"],"summary":"Burn subtitles into a generated or uploaded video","description":"Adds styled, burned-in subtitles to a video and returns a `jobId` immediately. **The captioned video is not ready when this returns** — poll `GET /generations/{jobId}` and download through `/watch`, exactly as for `POST /videos`.\n\n**The source is either provenance.** Pass `jobId` for a video this API generated, or `assetId` for one you uploaded with `POST /uploads`. Exactly one — sending both is a `400` rather than a guess, because captioning the wrong one of two sources still bills you for it. `POST /videos/{jobId}/captions` is the same operation with the source in the path, and remains the natural call when the source is a job; it cannot express the upload case, because an `assetId` contains slashes and is not a path segment.\n\n**There is nothing to write.** The subtitle text is transcribed from the video's own audio. The only choice is `preset`, the visual style, and it is **required** — the tiers are priced differently, so there is no default that could be right for both.\n\n**Priced per minute of video**, on the same meter fal bills us: `rate x whole minutes, rounded up, minimum one`, doubled again above the 1080p tier. So a 15-second clip costs 0.4 credits at a basic style and a 10-minute upload costs 4. Resolution is measured on the **short edge** — an ordinary portrait `1080x1920` is 1080p held sideways and is **not** doubled; a true 4K source is. Price any source before you send it with `POST /estimates`.\n\nThe duration and resolution are read from the file itself at request time, not from anything you declare. A source that cannot be read answers `400` and **charges nothing** — a file we cannot measure is one we cannot price.\n\nAn `assetId` must name a **video** you uploaded. A still answers `400`; an id outside your organization, or one whose PUT was never completed, answers `404` — identically to one that does not exist, because a distinguishable response would be a way to probe what else exists.\n\nA video rendered with `audioEnabled: false` answers `409`: it has no speech to transcribe, and captioning it would produce an empty result you were still charged for.\n\n**Captioning the same video in the same style twice is safe.** The second call returns the FIRST job's id and charges nothing. A *different* style on the same video is a new job, not a conflict.\n\nCaptions have their own concurrency ceiling, counted separately from the render budget so a batch of captions can never block your next generation. Hitting it answers `429` with `error.details.reason` = `caption_concurrency_limit`.","requestBody":{"description":"The source, and which style to burn in.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCaptionJobRequest"}}}},"responses":{"202":{"description":"Accepted, charged, and queued for captioning.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptionJob"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits. `error.details` carries `required` and `available`, in credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such job or asset for this organization, or the upload was never completed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The source job has not succeeded yet, or it was rendered without audio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. **Do not blindly retry**: this endpoint charges credits, and a failure can land after the debit committed, so a retry may pay twice. Call `GET /generations` first — if the work is there, it ran.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/caption-presets":{"get":{"operationId":"listCaptionPresets","tags":["Captions"],"summary":"List the caption styles","description":"Every subtitle style `POST /captions` will burn in, with its tier and its per-minute rate in credits.\n\nTwo tiers: `basic` at 0.4 credits per billed minute and `dynamic` — context-aware and animated — at 0.8. Minutes round UP with a one-minute minimum, so a video of a minute or less costs exactly the figure shown; a 10-minute source costs ten times it. Anything above the 1080p tier doubles again, measured on the SHORT edge (a portrait `1080x1920` is not above it).\n\nIts own resource rather than a `kind` on `GET /models`, because that endpoint answers what this API can GENERATE with. A caption style generates nothing; it is applied to a video that already exists.","responses":{"200":{"description":"The styles and their prices.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptionPresetList"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/generations":{"get":{"operationId":"listGenerations","tags":["Jobs"],"summary":"List your recent jobs","description":"Your organization's generation jobs, newest first by default, with status, credits spent and — for finished jobs — a download URL.\n\nThis is the recovery path when a generate call times out: the response carrying the `jobId` is the thing that was lost, so `GET /generations/{jobId}` cannot help, while the work may already have completed and been charged.\n\nFor incremental sync, store the highest `updatedAt` you have seen and pass it as `updatedSince` rather than re-walking pages.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":50,"description":"1 to 50. Defaults to 10."},"required":false,"description":"1 to 50. Defaults to 10.","name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Rows to skip. Defaults to 0."},"required":false,"description":"Rows to skip. Defaults to 0.","name":"offset","in":"query"},{"schema":{"type":"string","enum":["video","image","audio","text"],"description":"Narrow to one kind. `audio` is a music job and `text` is a transcript — these are the row's OWN kind, which is why neither is spelled the way its estimate arm is (`music`, `transcript`). That arm names the operation; this names the output."},"required":false,"description":"Narrow to one kind. `audio` is a music job and `text` is a transcript — these are the row's OWN kind, which is why neither is spelled the way its estimate arm is (`music`, `transcript`). That arm names the operation; this names the output.","name":"kind","in":"query"},{"schema":{"type":"string","description":"Narrow to one product (`GET /v1/products`)."},"required":false,"description":"Narrow to one product (`GET /v1/products`).","name":"productId","in":"query"},{"schema":{"type":["string","null"],"format":"date-time","description":"ISO 8601 instant. Returns only jobs written at or after it — the incremental-sync path: store the highest `updatedAt` you have seen and ask for everything since, instead of re-walking pages."},"required":false,"description":"ISO 8601 instant. Returns only jobs written at or after it — the incremental-sync path: store the highest `updatedAt` you have seen and ask for everything since, instead of re-walking pages.","name":"updatedSince","in":"query"},{"schema":{"type":"string","enum":["createdAt","updatedAt"],"description":"Defaults to createdAt."},"required":false,"description":"Defaults to createdAt.","name":"sortBy","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"description":"Defaults to desc (newest first)."},"required":false,"description":"Defaults to desc (newest first).","name":"sortOrder","in":"query"}],"responses":{"200":{"description":"A page of jobs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationList"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/generations/{jobId}":{"get":{"operationId":"getGeneration","tags":["Jobs"],"summary":"Check one job","description":"The job's status and, once it has succeeded, a time-limited `outputUrl`.\n\nThis call also DRIVES completion rather than only observing it: provider webhooks are best-effort and some providers are poll-only, so a job whose webhook was dropped is finalized here. Poll every few seconds; the rate limit is sized for it.","parameters":[{"schema":{"type":"string","description":"`jobId` from POST /v1/videos or POST /v1/images.","example":"f2cd403d-8b60-49dd-885c-091b83f1c597"},"required":true,"description":"`jobId` from POST /v1/videos or POST /v1/images.","name":"jobId","in":"path"}],"responses":{"200":{"description":"The job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Generation"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such job for this organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/generations/{jobId}/watch":{"get":{"operationId":"watchGeneration","tags":["Jobs"],"summary":"Redirect to the finished asset","description":"`302` to a freshly signed download URL for a finished job — `curl -L` friendly, and the right thing to hand a video player or an `<img>` tag, because the redirect is followed with a signature that is minted at request time rather than one that may have expired in your database.\n\nWhile the job is unfinished this is a `409` whose body names the current status, so a caller polling this endpoint alone still learns where the job is.","parameters":[{"schema":{"type":"string","description":"`jobId` from POST /v1/videos or POST /v1/images.","example":"f2cd403d-8b60-49dd-885c-091b83f1c597"},"required":true,"description":"`jobId` from POST /v1/videos or POST /v1/images.","name":"jobId","in":"path"}],"responses":{"302":{"description":"Redirect to a presigned download URL, in the `Location` header.","headers":{"Location":{"description":"The presigned URL. Time-limited; follow it promptly.","schema":{"type":"string","format":"uri"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such job for this organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The job has not produced an asset. `error.details.status` is where it currently is.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products":{"get":{"operationId":"listProducts","tags":["Products"],"summary":"List your products","description":"The products your organization has, newest first by default. A product is a folder for work: pass its `id` as `productId` when generating and the job is filed under it.\n\n**A product does not influence what is generated.** None of its fields reach the prompt. To change the output, change the prompt or attach a reference image to the generate call.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"1 to 100. Defaults to 20."},"required":false,"description":"1 to 100. Defaults to 20.","name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Rows to skip. Defaults to 0."},"required":false,"description":"Rows to skip. Defaults to 0.","name":"offset","in":"query"},{"schema":{"type":["string","null"],"format":"date-time","description":"ISO 8601 instant. Returns only products written at or after it."},"required":false,"description":"ISO 8601 instant. Returns only products written at or after it.","name":"updatedSince","in":"query"},{"schema":{"type":"string","enum":["createdAt","updatedAt"],"description":"Defaults to createdAt."},"required":false,"description":"Defaults to createdAt.","name":"sortBy","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"description":"Defaults to desc (newest first)."},"required":false,"description":"Defaults to desc (newest first).","name":"sortOrder","in":"query"}],"responses":{"200":{"description":"A page of products.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductList"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createProduct","tags":["Products"],"summary":"Create a product","description":"Creates a product and returns it. Only `name` is required.\n\nNames do not have to be unique — two products called the same thing both succeed and get distinct ids.","requestBody":{"description":"The product to create.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductRequest"}}}},"responses":{"201":{"description":"The created product.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Product"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/{productId}":{"get":{"operationId":"getProduct","tags":["Products"],"summary":"Get one product","description":"One product. A product id belonging to another organization returns the same `not_found` a nonexistent one does.","parameters":[{"schema":{"type":"string","description":"`id` from POST /v1/products or GET /v1/products."},"required":true,"description":"`id` from POST /v1/products or GET /v1/products.","name":"productId","in":"path"}],"responses":{"200":{"description":"The product.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Product"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such product for this organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateProduct","tags":["Products"],"summary":"Update a product","description":"Updates the fields you send and returns the whole product. Send at least one field; a body that changes nothing is refused rather than answered with a silent 200.\n\nFields you omit are left alone. To clear one, send it as an empty string.","parameters":[{"schema":{"type":"string","description":"`id` from POST /v1/products or GET /v1/products."},"required":true,"description":"`id` from POST /v1/products or GET /v1/products.","name":"productId","in":"path"}],"requestBody":{"description":"The fields to change.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProductRequest"}}}},"responses":{"200":{"description":"The updated product.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Product"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such product for this organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteProduct","tags":["Products"],"summary":"Delete a product","description":"Deletes a product. It stops appearing in every read: fetching it afterwards returns `not_found`.\n\n**Your work is not deleted.** The generations, videos and projects filed under it are preserved — this removes the folder, not its contents. That is also why a product with projects still under it is refused with `409` rather than taking them with it.","parameters":[{"schema":{"type":"string","description":"`id` from POST /v1/products or GET /v1/products."},"required":true,"description":"`id` from POST /v1/products or GET /v1/products.","name":"productId","in":"path"}],"responses":{"204":{"description":"Deleted. No body."},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such product for this organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The product still has projects under it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/products/{productId}/folders":{"get":{"operationId":"listProductFolders","tags":["Products"],"summary":"List a product's folders","description":"The folders under one product. Folders group projects inside the dashboard; this endpoint reports which exist.","parameters":[{"schema":{"type":"string","description":"`id` from POST /v1/products or GET /v1/products."},"required":true,"description":"`id` from POST /v1/products or GET /v1/products.","name":"productId","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"1 to 100. Defaults to 20."},"required":false,"description":"1 to 100. Defaults to 20.","name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Rows to skip. Defaults to 0."},"required":false,"description":"Rows to skip. Defaults to 0.","name":"offset","in":"query"}],"responses":{"200":{"description":"A page of folders.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderList"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such product for this organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/models":{"get":{"operationId":"listModels","tags":["Reference"],"summary":"What this API can generate with","description":"The models behind `/videos` and `/images`, with their accepted durations and aspect ratios, prompt ceilings, and the credit cost of one representative output. Derived from the same configuration the generation paths enforce and charge with, so a duration listed here is a duration `/videos` accepts.\n\n`credits` prices each model at ITS OWN default length, which differs between models — read it beside `representativeOutput` rather than as a like-for-like comparison. `POST /estimates` prices an exact request.","responses":{"200":{"description":"The catalog.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Models"}}}},"400":{"description":"The request failed validation. `error.details.issues` names each bad field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, malformed or revoked API key. The response carries a `WWW-Authenticate: Bearer` challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated, but this organization may not use the endpoint — it has no live subscription at all (`error.details.reason` is `plan_required`), its plan has ended (`subscription_inactive`), or the API is not enabled for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such object for this organization. Deliberately indistinguishable from an object that belongs to someone else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Always retry after the number of seconds in `Retry-After`, and read `error.details.reason` to know WHICH ceiling refused you — the eleven are paced differently and `X-RateLimit-*` describes only the first:\n· `key_limit` — 60 requests per minute for this API key. The `X-RateLimit-*` trio tracks this one.\n· `organization_limit` — 180 requests per minute across every key this organization holds. `X-RateLimit-*` will still show room on your key; that is correct and not a broken limiter.\n· `concurrency_limit` — 5 generations already running through the API for this organization. Waiting is the only fix: `details.inFlight` says how many, and no amount of slowing down helps until one finishes. Generation endpoints only.\n· `caption_concurrency_limit` — 10 caption jobs already running through the API for this organization. Counted SEPARATELY from `concurrency_limit` so a batch of captions can never block your next render, which is why it has its own reason rather than reusing that one.\n· `transcript_concurrency_limit` — 10 transcripts already running through the API for this organization, on deployments that offer `POST /transcripts`. A THIRD queue, counted apart from both of the above, so transcribing a batch never blocks a render or a caption. It is also the one that binds soonest in practice: a transcript is synchronous, so this number bounds how many transcriptions you can hold open at once.\n· `voiceover_concurrency_limit` — 10 voice-overs already running through the API for this organization, on deployments that offer `POST /voiceovers`. A FOURTH queue, and the one most likely to surprise: the flow this endpoint exists for narrates the gaps between clips it is still rendering, so hitting this while renders are in flight means waiting on a two-second TTS call, not on the renders.\n· `image_concurrency_limit` — 12 image generations already running through the API for this organization. A FIFTH queue, and the widest: images finish in about a minute, so this is sized to let a whole batch of ad variations render at once rather than in waves. Counted apart from `concurrency_limit` in BOTH directions — a batch of images never blocks `POST /videos`, and five renders in flight never block `POST /images`.\n· `competitor_ads_concurrency_limit` — 10 competitor-ad sweeps already running through the API for this organization, on deployments that offer `POST /competitor-ads`. A SIXTH queue. Like transcripts it is synchronous, so this bounds how many sweeps you can hold open at once — and unlike every other queue here, your renders are provably unaffected, so the fix is to wait about ten seconds rather than to slow anything else down.\n· `analysis_concurrency_limit` — 4 ad analyses already running for this organization, on deployments that offer `POST /analyses`. A SEVENTH queue, and the narrowest on this page by some way: an analysis holds the whole uploaded file in memory while the model reads it, so this one is sized against our memory rather than against a provider's patience. Renders are unaffected, and each analysis clears in well under a minute.\n· `analysis_hourly_limit` — this organization has used its hourly allowance of ad analyses, on deployments that offer `POST /analyses`. **Not a queue**: nothing is in flight to wait for, so `details.inFlight` is absent and the only fix is the rolling window in `retryAfterSeconds`. It is the one ceiling on this list that counts calls per hour rather than calls at once.\n· `client_limit` — 1200 requests per minute before authentication, so a bad key cannot be brute-forced. Carries no `X-RateLimit-*` trio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. This endpoint charges nothing and creates nothing, so a retry is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"A generation provider failed. Any credits charged for the attempt are refunded automatically.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"webhooks":{}}