Quickstart
Generate a video with one API key and three requests: get a quote, create the generation, then poll until it's ready.
1. Create an API key
In the console, open Developers → API Keys and create a key. Send it as a Bearer token on every request. Generations are charged to your wallet; you can cap each key with a spend limit.
2. Get a quote
curl https://api.makernice.com/v1/generations/quote \
-H "Authorization: Bearer $MAKERNICE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"seedance-2.5","mode":"t2v","prompt":"A slow dolly shot through a greenhouse","duration":5,"resolution":"720p"}'
# → { "id": "qt_…", "amount": "500000", "expires_at": "…", "facts": { "quantity": 5, … } }Amounts are in US dollars, in millionths of a dollar (micro-USD): 1,000,000 = $1.00. A quote locks the price for 12 minutes.
3. Create the generation
curl https://api.makernice.com/v1/generations \
-H "Authorization: Bearer $MAKERNICE_API_KEY" \
-H "Idempotency-Key: 7c1e9a2b-order-1842" \
-H "Content-Type: application/json" \
-d '{"quote_id":"qt_…","input":{"model":"seedance-2.5","mode":"t2v","prompt":"A slow dolly shot through a greenhouse","duration":5,"resolution":"720p"}}'
# → 202 { "id": "gen_…", "status": "queued", "billing": "held", … }The amount is held from your balance. If the generation fails or you cancel it while queued, the hold is released in full. Retrying with the same Idempotency-Key returns the same generation instead of charging twice.
4. Poll until it's ready
curl https://api.makernice.com/v1/generations/gen_… \
-H "Authorization: Bearer $MAKERNICE_API_KEY"
# status: queued → generating → moderating → succeeded | failed | canceled
# when succeeded: outputs[0].url → GET it with your key to receive a 302 to the file5. Download the file
curl -L -o video.mp4 https://api.makernice.com/v1/generations/gen_…/outputs/0 \
-H "Authorization: Bearer $MAKERNICE_API_KEY"Authentication
Every request needs an API key in the Authorization header, except GET /v1/models and GET /v1/pricing. Keys look like mn- followed by 48 letters and digits.
curl https://api.makernice.com/v1/generations \
-H "Authorization: Bearer $MAKERNICE_API_KEY"- The full key is shown only once, when you create it. We store only a fingerprint, so a lost key can't be recovered: create a new one and delete the old one.
- A missing, malformed, unknown, disabled, expired or deleted key always gets the same
401 invalid_api_keyresponse. Disabling or deleting a key takes effect on the next request. - Call the API from your server. It doesn't accept cross-origin browser requests (no CORS); never put a key in front-end code.
- Every key in an account sees the same generations: any key can get, list and cancel all of the account's generations, including the ones created in the console.
- In the console you can give a key an expiry date and a spend limit. A key's spend is the charged plus the currently held amount of the generations it created; a create request that would go over the limit gets
402 key_spend_limit_reached. Failed and canceled generations don't count.
Pricing & billing
- Billing is in US dollars. All amounts are integer strings of micro-USD (millionths of a dollar):
1,000,000= $1.00. Parse them as integers or decimals, not floating-point numbers. - Videos are priced per second (the quantity is the duration), images per request (one image per generation). Price = unit price × quantity.
- A quote locks the price for 12 minutes (
expires_at). The generation is charged the quoted amount, even if prices change in the meantime. - When you create a generation, the amount is held from your balance (
billing: held). It's charged when the generation succeeds (charged) and released in full if it fails or is canceled (released). While a generation ischecking, the amount stays held.
GET /v1/pricing lists the unit prices of the current price version. It needs no API key and may be cached for up to 60 seconds. official_unit_price, when present, is the official list price of the same combination for comparison only; you are always billed unit_price.
{
"price_version": 3,
"data": [
{ "model": "seedance-2.5", "mode": "t2v", "resolution": "720p", "unit": "second", "unit_price": "100000", "unit_price_usd": "0.10", "official_unit_price": "120000" }
]
}Get a quote
Send the generation input; the response tells you exactly what the generation will cost. Each quote can be used for one generation.
| Field | Type | Description |
|---|---|---|
model | string | Required. A model id from GET /v1/models. |
mode | string | Required. t2v, i2v, flf, r2v, t2i or i2i; each model lists the modes it supports. |
prompt | string | Required. 1–4,000 characters by default; some models allow fewer (see maxPromptChars in GET /v1/models). |
duration | integer | Video only. Seconds, within the model's range; defaults to the model's default. |
resolution | string | One of the model's resolutions; defaults to defaultResolution. Not allowed when the model lists none. |
aspect_ratio | string | One of the mode's aspectRatios. |
image_url | string | First frame. Required for i2v and flf. |
last_image_url | string | Last frame. Required for flf. |
reference_images | string[] | Required for r2v and i2i; at most the mode's maxReferences. |
seed | integer | Optional. 0–2,147,483,647. The same seed with the same settings gives closer results. |
generate_audio | boolean | Optional. Whether the video gets a soundtrack. |
prompt_expansion | string | Optional. off, balanced or quality: how much the model may rewrite your prompt. |
negative_prompt | string | Optional. Up to 2,000 characters describing what to avoid. |
watermark | boolean | Optional. Adds the provider's AI-generated watermark. |
The last five fields are optional extras. Each model supports only some of them: GET /v1/models lists the ones available for every mode and resolution in prices[].options, with allowed values and defaults. A field no model route can apply returns 400 invalid_request; changing an optional field needs a new quote, like any other field.
Images are public https URLs (up to 2,048 characters). Fields that the mode doesn't use, and unknown fields, are rejected with 400 invalid_request; for input fields, error.details lists each problem as { path, message } (other invalid_request errors may have a different details shape or none). A combination without a price returns 422 price_unavailable.
Each image must be a publicly reachable https URL that serves a still JPEG, PNG or WebP image (animated WebP and GIF are not supported), at most 10 MB, directly at that URL: redirects are not followed. We fetch each image once and keep a copy; content moderation and the model both use that copy. If an image can't be used, the generation fails with invalid_request, error.input names the image, and the hold is released.
curl https://api.makernice.com/v1/generations/quote \
-H "Authorization: Bearer $MAKERNICE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"seedance-2.5","mode":"t2v","prompt":"A slow dolly shot through a greenhouse","duration":5,"resolution":"720p"}'{
"id": "qt_6f1c…",
"amount": "500000",
"price_version": 3,
"expires_at": "2026-10-09T09:24:41.000Z",
"facts": { "model": "seedance-2.5", "mode": "t2v", "resolution": "720p", "variant": "default", "unit": "second", "quantity": 5 }
}Create a generation
Create the generation with the quote and the same input. The Idempotency-Key header is required (see Idempotency).
curl https://api.makernice.com/v1/generations \
-H "Authorization: Bearer $MAKERNICE_API_KEY" \
-H "Idempotency-Key: 7c1e9a2b-order-1842" \
-H "Content-Type: application/json" \
-d '{"quote_id":"qt_6f1c…","input":{"model":"seedance-2.5","mode":"t2v","prompt":"A slow dolly shot through a greenhouse","duration":5,"resolution":"720p"}}'- Responds
202with the generation (status: queued,billing: held). A retry with the same Idempotency-Key responds200with the original generation. - You may change the prompt, but changing anything that affects the price (model, mode, resolution, duration) returns
422 quote_mismatch. - A used quote returns
409 quote_already_used, an expired one410 quote_expired, and one that isn't in your account404 quote_not_found. - Not enough balance returns
402 insufficient_balance; going over the key's spend limit returns402 key_spend_limit_reached. - Optional top-level
callback_url(https, up to 2048 characters, no credentials in the URL, not a private or reserved address): when the generation finishes, we POST it there. It takes priority over your account's webhook address. See Webhooks.
Check status
Poll the generation until it reaches a final status. Polling every few seconds is fine; see the read limit in Rate limits.
| Status | Meaning |
|---|---|
queued | Waiting to start. Can still be canceled. |
generating | The model is working on it. |
moderating | Generation finished and the outputs are in a safety check; outputs stays empty until it completes. Keep polling. |
succeeded | Done. The amount is charged and outputs are ready. |
failed | Didn't finish. The hold is released; error has a code and a message. |
canceled | Canceled while queued. The hold is released. |
checking | We're confirming the result. The amount stays held until it's resolved. |
error.code of a failed generation is one of invalid_request (the model rejected the request; adjust the prompt or settings), content_rejected (rejected by content moderation or the model provider's safety policy; the hold is released), timeout, upstream_unavailable or generation_failed. For content_rejected, error.reason says what was rejected: prompt (the prompt), input_image (a reference image) or model (the model provider's safety policy). The same reason appears in the 422 response when a submitted prompt is blocked. When invalid_request is caused by a reference image that can't be used, error.input names it: image_url, last_image_url or reference_images[0] (zero-based). Replace that image and create a new generation.
{
"id": "gen_3b9e…",
"type": "video",
"model": "seedance-2.5",
"mode": "t2v",
"status": "succeeded",
"prompt": "A slow dolly shot through a greenhouse",
"params": { "duration": 5, "resolution": "720p", "aspectRatio": null },
"amount": "500000",
"billing": "charged",
"price_version": 3,
"outputs": [
{
"index": 0,
"url": "/v1/generations/gen_3b9e…/outputs/0",
"state": "stored",
"contentType": "video/mp4",
"expiresAt": "2026-11-08T09:14:03.000Z"
}
],
"error": null,
"created_at": "2026-10-09T09:12:41.000Z",
"finished_at": "2026-10-09T09:14:03.000Z"
}90 days after a generation finishes, its request content is cleared under our data retention policy: prompt becomes an empty string. Billing details (model, params, amount, status and timestamps) are kept.
GET /v1/generations/{id}/events returns the generation's timeline. Codes: created, submitted, generating, delivered, failed_refunded, canceled_refunded, checking, requeued.
{
"data": [
{ "code": "created", "at": "2026-10-09T09:12:41.000Z" },
{ "code": "submitted", "at": "2026-10-09T09:12:43.000Z" },
{ "code": "generating", "at": "2026-10-09T09:12:50.000Z" },
{ "code": "delivered", "at": "2026-10-09T09:14:03.000Z" }
]
}Download outputs
Each item in outputs has a url such as /v1/generations/gen_…/outputs/0, relative to https://api.makernice.com. Request it with your API key; the response is a 302 redirect to a short-lived link to the file. Request the endpoint again whenever you need the file instead of storing the redirect target.
curl -L -o video.mp4 https://api.makernice.com/v1/generations/gen_…/outputs/0 \
-H "Authorization: Bearer $MAKERNICE_API_KEY"- Add
?download=1to have the file served as an attachment. This works once the file isstored; while it ispendingordegradedthe redirect goes to the original file and the parameter has no effect. curl -Ldoesn't send yourAuthorizationheader to the redirect host, so your key stays with us.- Outputs exist only when the generation has
succeeded; otherwise the endpoint returns404 output_not_found. state:pending(still being saved; the link already works),stored(saved untilexpiresAt),degraded(saving didn't complete; the link may stop working sooner — download it soon),expired(the saved file has expired; the endpoint returns410 output_expired),blocked(the output didn't pass content moderation;urlis an empty string — contact support for a manual review).
Cancel
Only queued generations can be canceled; the hold is released in full and the response is the canceled generation. Once a generation is generating it can't be stopped, and cancel returns 409 not_cancelable.
curl -X POST https://api.makernice.com/v1/generations/gen_…/cancel \
-H "Authorization: Bearer $MAKERNICE_API_KEY"
# → { "id": "gen_…", "status": "canceled", "billing": "released", … }List generations
Lists the generations in your account, newest first — including the ones created in the console.
| Query | Description |
|---|---|
limit | Page size, 1–100. Default 20. |
cursor | The next_cursor of the previous page. |
status | One or more statuses, comma-separated, e.g. queued,generating. |
type | video or image. |
model | A model id. |
q | Part of a generation id, or text contained in the prompt (both case-insensitive). |
curl "https://api.makernice.com/v1/generations?status=queued,generating&limit=50" \
-H "Authorization: Bearer $MAKERNICE_API_KEY"
# → { "data": [ …generations… ], "next_cursor": "gen_…" | null, "total": 12 }total counts all generations matching the filters. When next_cursor is null, you've reached the last page.
Webhooks
Instead of polling, you can have us POST the generation to your server when it reaches a final status. We send to the callback_url given when creating the generation; without one, to your account's webhook address (set it in console settings). With neither, nothing is sent.
| Event | Sent when |
|---|---|
generation.succeeded | The generation succeeded and was charged. |
generation.failed | The generation failed; the held amount was released. |
generation.canceled | The generation was canceled; the held amount was released. |
Request
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
Makernice-Signature: t=1791364443,v1=5f0c2d…e81a
Makernice-Event-Id: evt_1b7f…{
"id": "evt_1b7f…",
"type": "generation.succeeded",
"created_at": "2026-10-09T09:14:03.000Z",
"data": {
"id": "gen_3b9e…",
"status": "succeeded",
"billing": "charged",
"outputs": [
{ "index": 0, "url": "https://api.makernice.com/v1/generations/gen_3b9e…/outputs/0", "state": "stored", … }
],
…
}
}datais the same generation object asGET /v1/generations/{id}, except thatoutputs[].urlis an absolute URL.Makernice-Event-Idequals the body'sidand stays the same across retries. Use it to ignore duplicates.
Verify the signature
Makernice-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 with your signing secret over t + "." + raw body. Compute it over the raw bytes you received, compare in constant time, and reject requests whose t is more than 5 minutes from your clock. The signing secret is in console settings; callback_url deliveries are signed with the same secret. After you rotate it, deliveries not yet sent are signed with the new one.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the body exactly as received (Buffer). Don't re-serialize parsed JSON.
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}import hashlib
import hmac
import time
# raw_body: the body exactly as received (bytes). Don't re-serialize parsed JSON.
def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Delivery and retries
- HTTPS only. Redirects aren't followed. Each attempt times out after 10 seconds.
- Any 2xx response counts as delivered. Respond quickly and do heavy work afterwards.
- Otherwise we retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h — 7 attempts in total — and then mark the delivery failed. Recent deliveries and their last response are shown in console settings.
- Each generation normally sends one event, and a delivery can arrive more than once (for example after a timeout on your side). If an output held back by content review is later released, we send the generation again with a new event id, so you may get two events for it; the latest one wins.
Models
GET /v1/models lists the models and the modes, resolutions and durations you can use. Only modes that have a price are listed, with their prices in modes[].prices. It needs no API key and may be cached for up to 60 seconds.
resolutions/defaultResolution: allowed values forresolution. An empty list means the model has a fixed resolution and you leaveresolutionout. A listed resolution may have no price in some modes: checkmodes[].prices; quoting it returns422 price_unavailable.durations:{ min, max, default }in seconds for videos,nullfor images.modes[].needs: which images the mode requires (image→image_url,lastImage→last_image_url,references→reference_images, up tomaxReferences).modes[].aspectRatios: allowedaspect_ratiovalues.modes[].prices: unit price per resolution.
{
"data": [
{
"id": "seedance-2.5",
"type": "video",
"resolutions": ["480p", "720p", "1080p", "4k"],
"defaultResolution": "720p",
"durations": { "min": 4, "max": 30, "default": 5 },
"modes": [
{
"mode": "t2v",
"needs": { "image": false, "lastImage": false, "references": false },
"maxReferences": null,
"aspectRatios": ["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "9:21"],
"prices": [{ "resolution": "720p", "unit": "second", "unitPrice": "100000" }]
}
]
}
]
}Errors
Errors are JSON: { "error": { "code", "message", "reason"?, "details"?, "request_id" } }. Branch on code; messages may change. Every response carries an x-request-id header (same value as request_id); include it when contacting support so we can find the request.
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | The body, query or a header is invalid. details lists each field and the problem; fix the request. |
| 401 | invalid_api_key | The key is missing, malformed, disabled, expired or deleted. |
| 402 | insufficient_balance | Top up your wallet. |
| 402 | key_spend_limit_reached | Raise the key's spend limit or use another key. |
| 402 | daily_spend_limit_reached | Your account reached its daily spend limit; it resets at 00:00 UTC. Contact support to raise it. |
| 403 | org_frozen | Your account is frozen. Contact support. |
| 404 | quote_not_found | The quote doesn't exist in your account. Get a new quote. |
| 404 | task_not_found | The generation doesn't exist in your account. Check the id. |
| 404 | output_not_found | The generation hasn't succeeded or has no output with that index. |
| 404 | not_found | Unknown endpoint. Check the method and path. |
| 409 | quote_already_used | Get a new quote. |
| 409 | not_cancelable | Only queued generations can be canceled. |
| 410 | quote_expired | Get a new quote. |
| 410 | output_expired | The stored file has expired and can no longer be downloaded. |
| 413 | payload_too_large | The request body is over 64 KB. Shorten the prompt or send fewer image URLs. |
| 422 | idempotency_conflict | This Idempotency-Key was used for a different request. Use a new key. |
| 422 | quote_mismatch | The input changes the price (model, mode, resolution or duration). Quote this input again. |
| 422 | price_unavailable | This combination has no price. Pick one listed by GET /v1/models. |
| 422 | content_rejected | Rejected by content moderation or the model provider's safety policy; error.reason says which (prompt, input_image or model). Change the prompt or images; you were not charged. |
| 429 | rate_limited | Wait for Retry-After seconds, then retry. |
| 429 | concurrency_limit_reached | Too many generations in progress for your account. Wait for one to finish (see Retry-After), or contact support to raise the limit. |
| 500 | internal_error | Unexpected server error. Retry with backoff; include request_id if you contact support. |
For unexpected 5xx responses or network errors, retry with backoff. When retrying a create request, reuse the same Idempotency-Key so you're never charged twice.
Rate limits
Limits are per minute. Going over returns 429 rate_limited with a Retry-After header (seconds).
| Scope | Limit |
|---|---|
| Per key — writes (quote, create, cancel) | 60 / min |
| Per key — reads (get, list, events, outputs) | 600 / min |
| Per account — writes across all keys | 120 / min |
| Per IP — models, pricing and requests with a rejected key | 120 / min |
A key can also have at most 4 create requests in progress at the same time; more return 429 as well. Limits may be adjusted.
Each account also has two usage limits, by default 2 generations in progress at once and US$100 of spend per UTC day. Going over returns 429 concurrency_limit_reached (with Retry-After) or 402 daily_spend_limit_reached. Contact support to raise them.
Idempotency
POST /v1/generationsrequires anIdempotency-Keyheader: 1–200 printable ASCII characters, no spaces. Use a new value, such as a UUID, for each new generation.- Keys are unique within your account and shared by all of its API keys.
- Repeating a request with the same key, quote and input returns the original generation with
200and doesn't charge again. - Using the same key with a different
quote_id, differentinput(including the prompt and image URLs) or a differentcallback_urlreturns422 idempotency_conflict. - Quotes and cancels take no Idempotency-Key. Repeating a cancel that already succeeded returns
409 not_cancelable; check the status withGET.