# NanoAPI > NanoAPI is an API platform for AI video generation. This file is the complete NanoAPI API documentation in Markdown, for AI assistants that write integration code: an index of the pages first, then the full text of every page. The same docs for people are at https://nanoapi.ai/docs. - Base URL: `https://api.nanoapi.ai/v1`, HTTPS and JSON only. Send `Authorization: Bearer ` with every request; keep the key on the server. - Every model is created with `POST /v1/tasks` (`model` picks the model). Tasks run in the background: get the result with `GET /v1/tasks/{task_id}` or a webhook. - Success is HTTP 200 with `{ "code": 200, "message": "success", "data": ... }`. Errors keep the HTTP status and return `{ "code", "message", "error": { "type", "param", "details" }, "request_id" }`. Branch on `error.type`, not on `message`. - Use only the endpoints, parameters and fields documented here; don’t invent others. ## Get started - [Quickstart](https://nanoapi.ai/docs): Four steps to your first call: create an API key, top up, create a task, get the result. ## Models - [Genjutsu 1.0](https://nanoapi.ai/docs/genjutsu): Generate a new video from a source video and reference images. ## Unified async API - [Create a task](https://nanoapi.ai/docs/tasks/create): The create endpoint shared by every model: parameters, request examples, response and every error. - [Retrieve a task](https://nanoapi.ai/docs/tasks/get): Get one task's status and result: response, errors, the task object and failure reasons. - [List tasks](https://nanoapi.ai/docs/tasks/list): Check several tasks at once, or filter them by status and creation time: parameters, response and errors. ## API reference - [Authentication & conventions](https://nanoapi.ai/docs/conventions): Shared by every endpoint: authentication, request and response format, rate limits, pagination, billing, amounts and IDs. - [Webhooks](https://nanoapi.ai/docs/webhooks): Get notified at your HTTPS endpoint when a task succeeds or fails. ## Core endpoints - [Balance](https://nanoapi.ai/docs/balance): Your available balance and the amount frozen for tasks in progress. - [Products](https://nanoapi.ai/docs/products): Available products with their modes, per-second prices and limits. --- # Quickstart Source: https://nanoapi.ai/docs Four steps to your first call: create an API key, top up, create a task, get the result. - **Base URL**: `https://api.nanoapi.ai/v1`, HTTPS and JSON only - **Auth**: header `Authorization: Bearer ` ### Create an API key Create one in [Console → API keys](https://nanoapi.ai/console/api-keys). **The full key is shown only once**, so store it right away: ```bash NANOAPI_API_KEY="nf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` ### Top up your balance [Top up](https://nanoapi.ai/console/recharge) in the console. The balance never expires. - Creating a task **freezes** its cost, e.g. $4.095 for a 15-second Genjutsu video at 720p - A successful task is **charged** (never more than the frozen amount); a failed one is **refunded in full** - If your available balance is too low, you get `402 insufficient_balance` and no task is created See [Billing](https://nanoapi.ai/docs/conventions#billing). ### Create a task `POST /v1/tasks` with the `model` to use; the other parameters are on each model's page. This example uses [Genjutsu 1.0](https://nanoapi.ai/docs/genjutsu) motion transfer: one photo of a person plus one source video, both as public URLs. **cURL** ```bash curl https://api.nanoapi.ai/v1/tasks \ -H "Authorization: Bearer $NANOAPI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "genjutsu-1.0", "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "resolution": "720p" }' ``` **Python** ```python title="create_task.py" import os import requests response = requests.post( "https://api.nanoapi.ai/v1/tasks", headers={"Authorization": f"Bearer {os.environ['NANOAPI_API_KEY']}"}, json={ "model": "genjutsu-1.0", "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "resolution": "720p", }, timeout=30, ) body = response.json() if body["code"] != 200: raise SystemExit(f"{body['error']['type']}: {body['message']}") task = body["data"] print(task["id"], task["billing"]["frozen"]) ``` **Node.js** ```js title="create-task.mjs" const response = await fetch('https://api.nanoapi.ai/v1/tasks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NANOAPI_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'genjutsu-1.0', mode: 'motion_control', image_urls: ['https://example.com/person.png'], video_url: 'https://example.com/dance.mp4', resolution: '720p', }), }); const body = await response.json(); if (body.code !== 200) { throw new Error(`${body.error.type}: ${body.message}`); } const task = body.data; console.log(task.id, task.billing.frozen); ``` - The response is `200` with the [task](https://nanoapi.ai/docs/tasks/get#task-object) in short form as `data`, normally `queued`; keep `data.id` to get the result - `data.billing.frozen` is the amount set aside - Every response is `{ code, message, data }`; on an error `code` is the HTTP status and `error.type` the error code, see [Response format](https://nanoapi.ai/docs/conventions#responses) ### Get the result: poll or webhook The video is generated in the background. **Polling**: `GET /v1/tasks/{id}` **at most once every 15 seconds** until `status` is `succeeded` or `failed`. **cURL** ```bash curl https://api.nanoapi.ai/v1/tasks/tk_01jb8z5q4w3e2r1t0y9u8i7o6p \ -H "Authorization: Bearer $NANOAPI_API_KEY" ``` **Python** ```python title="poll_task.py" import os import sys import time import requests headers = {"Authorization": f"Bearer {os.environ['NANOAPI_API_KEY']}"} url = f"https://api.nanoapi.ai/v1/tasks/{sys.argv[1]}" while True: response = requests.get(url, headers=headers, timeout=30) body = response.json() if body["code"] == 200 and body["data"]["status"] in ("succeeded", "failed"): break if body["code"] not in (200, 429): raise SystemExit(f"{body['error']['type']}: {body['message']}") time.sleep(int(response.headers.get("Retry-After", 15))) task = body["data"] if task["status"] == "succeeded": print(task["outputs"][0]["url"], task["billing"]["charged"]) else: print("failed:", task["error"]["type"]) ``` **Node.js** ```js title="poll-task.mjs" import { setTimeout as sleep } from 'node:timers/promises'; const headers = { Authorization: `Bearer ${process.env.NANOAPI_API_KEY}` }; const url = `https://api.nanoapi.ai/v1/tasks/${process.argv[2]}`; let body; while (true) { const response = await fetch(url, { headers }); body = await response.json(); if (body.code === 200 && ['succeeded', 'failed'].includes(body.data.status)) { break; } if (body.code !== 200 && body.code !== 429) { throw new Error(`${body.error.type}: ${body.message}`); } await sleep((Number(response.headers.get('retry-after')) || 15) * 1000); } const task = body.data; if (task.status === 'succeeded') { console.log(task.outputs[0].url, task.billing.charged); } else { console.log('failed:', task.error.type); } ``` **Webhook** (recommended in production): add a `webhook_url` when you create the task. When it finishes, NanoAPI posts the result to it. The body is the same as retrieving the task, and its `data` is the full task, so you don't need to poll. See [Webhooks](https://nanoapi.ai/docs/webhooks). | `data.status` | What to read | |---|---| | `succeeded` | `data.outputs[0].url` is the video, `data.billing.charged` is what you paid | | `failed` | `data.error.type` is the [failure reason](https://nanoapi.ai/docs/tasks/get#task-failures); the frozen amount is back in your balance | ## Next - [Genjutsu 1.0](https://nanoapi.ai/docs/genjutsu): Modes, parameters, media and pricing - [Unified async API](https://nanoapi.ai/docs/tasks/create): Create, retrieve and list tasks: responses and every error - [Webhooks](https://nanoapi.ai/docs/webhooks): Request format, retries and signatures - [Authentication & conventions](https://nanoapi.ai/docs/conventions): Auth, rate limits, billing and other shared rules --- # Genjutsu 1.0 Source: https://nanoapi.ai/docs/genjutsu Generate a new video from a source video and reference images. | `mode` | What it does | |---|---| | `motion_control` | Motion transfer: the person in your reference images performs the movements from the source video. | | `replace_object` | Object replacement: an object in the source video is replaced with the one in your reference images. | ## Create a task ``` POST https://api.nanoapi.ai/v1/tasks ``` Use the shared [create endpoint](https://nanoapi.ai/docs/tasks/create) with `model: "genjutsu-1.0"`. It freezes the cost from your available balance; the task is generated in the background and can't be cancelled. The endpoint usually responds in 1–3 seconds with `200` and the [task](https://nanoapi.ai/docs/tasks/get#task-object) in short form, normally `queued`; get the result by [webhook](https://nanoapi.ai/docs/webhooks) or with [`GET /v1/tasks/{id}`](https://nanoapi.ai/docs/tasks/get). | Parameter | Type | Required | Description | |---|---|---|---| | `model` | `string` | Yes | `genjutsu-1.0` (the only version for now). | | `mode` | `string` | Yes | `motion_control` or `replace_object`. | | `video_url` | `string` | Yes | Source video. See [Media](https://nanoapi.ai/docs/genjutsu#media). | | `image_urls` | `string[]` | Yes | 1–10 reference images. See [Media](https://nanoapi.ai/docs/genjutsu#media). | | `prompt` | `string` | No | At most 5000 characters. | | `resolution` | `string` | No | `480p`, `720p` (default) or `1080p`. Sets the [price](https://nanoapi.ai/docs/genjutsu#pricing). | `num_outputs` and `webhook_url` work the same for every model: see [Create a task](https://nanoapi.ai/docs/tasks/create). **cURL** ```bash curl https://api.nanoapi.ai/v1/tasks \ -H "Authorization: Bearer $NANOAPI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "genjutsu-1.0", "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "resolution": "720p", "webhook_url": "https://example.com/hooks/nanoapi" }' ``` **Python** ```python import os import requests response = requests.post( "https://api.nanoapi.ai/v1/tasks", headers={"Authorization": f"Bearer {os.environ['NANOAPI_API_KEY']}"}, json={ "model": "genjutsu-1.0", "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "resolution": "720p", "webhook_url": "https://example.com/hooks/nanoapi", }, timeout=30, ) body = response.json() print(body["code"], body.get("data") or body["error"]) ``` **Node.js** ```js const response = await fetch('https://api.nanoapi.ai/v1/tasks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NANOAPI_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'genjutsu-1.0', mode: 'motion_control', image_urls: ['https://example.com/person.png'], video_url: 'https://example.com/dance.mp4', resolution: '720p', webhook_url: 'https://example.com/hooks/nanoapi', }), }); const body = await response.json(); console.log(body.code, body.data ?? body.error); ``` **Response: `200 OK`**. `data` is the task in short form, with only these fields; get the parameters, results, duration and the rest with [`GET /v1/tasks/{id}`](https://nanoapi.ai/docs/tasks/get) (every field is described in [The task object](https://nanoapi.ai/docs/tasks/get#task-object)). ```json { "code": 200, "message": "success", "data": { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "queued", "billing": { "currency": "USD", "frozen": "4.095", "charged": "0.00", "refunded": "0.00", "billed_seconds": null }, "error": null, "created_at": "2026-10-22T08:30:00Z" } } ``` ## Media All media is passed as public http(s) URLs. There's no upload endpoint: host the files yourself, for example in your own object storage. URLs are at most 4096 characters and can't point to `localhost` or private, loopback or reserved addresses. | | Source video (`video_url`) | Reference images (`image_urls`) | |---|---|---| | Count | 1 | 1–10 | | Format | A direct download in mp4, mov, webm or mkv | Common image formats | | Length | At most 30 seconds; 4–30 seconds for `replace_object` | – | | If there's a problem | Can't be opened, too long or too short: the create endpoint returns `400` (see [Errors](https://nanoapi.ai/docs/genjutsu#errors)); no task, nothing frozen | Only read when generation runs: if one can't be opened the task fails and the frozen amount is returned in full | Keep every link reachable until the task finishes, including links that expire. ## Pricing ``` cost = billed seconds of the source video × price per second of the resolution ``` | Resolution | Price per second | 30-second video | |---|---|---| | 480p | $0.117 | $3.51 | | 720p | $0.273 | $8.19 | | 1080p | $0.78 | $23.40 | Billed seconds: - **Length**: the longer of the video and audio tracks, not the duration in the file's metadata. A few tens of milliseconds of encoding error are ignored (a video that's exactly 30 s but reports 30.02 s is accepted). - **`motion_control`**: rounded to the nearest second, at least 4 seconds. - **`replace_object`**: rounded up to a whole second. Example: a 15-second video at 720p freezes $4.095 and is charged $4.095 when it succeeds. How freezing and charging work in general: [Billing](https://nanoapi.ai/docs/conventions#billing). To show a price before submitting, read the video's length on your side and apply the rules above. It's an estimate: `billing.frozen` in the response is what counts. ## Errors Errors specific to this model. Every error of the create endpoint and how to handle it: [Create a task → Errors](https://nanoapi.ai/docs/tasks/create#errors). | Status | `error.type` | When | |---|---|---| | 400 | `invalid_media` | The source video can't be read: the link doesn't open or has expired, isn't public, or isn't a supported video. `details.reason` says why. | | 400 | `video_too_long` | The source video is longer than 30 seconds (`details.video_duration`). | | 400 | `video_too_short` | The `replace_object` source video is shorter than 4 seconds. | | 402 | `insufficient_balance` | Your available balance can't cover the cost (`details.required`, `details.available`). | | 503 | `service_unavailable` | The price can't be calculated right now. No task was created; retry after `Retry-After` seconds. | None of these create a task or freeze anything. --- # Create a task Source: https://nanoapi.ai/docs/tasks/create The create endpoint shared by every model: parameters, request examples, response and every error. ``` POST https://api.nanoapi.ai/v1/tasks ``` Every generation is a task. Every model creates tasks with this endpoint, picked with `model`. Then [retrieve the task](https://nanoapi.ai/docs/tasks/get) or [list tasks](https://nanoapi.ai/docs/tasks/list) for the result, or wait for the [webhook](https://nanoapi.ai/docs/webhooks). Every response uses the `{ code, message, data }` envelope, see [Response format](https://nanoapi.ai/docs/conventions#responses). Freezes the cost from your available balance; the task is generated in the background and can't be cancelled. The endpoint usually responds in 1–3 seconds. ## Parameters The parameters below work the same for every model. The rest (such as `mode`, `video_url` and `image_urls`) depend on the model and are on its page: [Genjutsu 1.0](https://nanoapi.ai/docs/genjutsu#create). | Parameter | Type | Required | Description | |---|---|---|---| | `model` | `string` | Yes | The model, such as `genjutsu-1.0`. Each model's page lists it; [`GET /v1/products`](https://nanoapi.ai/docs/products) returns them in `models`. | | `num_outputs` | `integer` | No | Only `1` for now (the default). | | `webhook_url` | `string` | No | Notified when the task succeeds or fails: `https`, up to 2048 characters, not a private, loopback or reserved address. See [Webhooks](https://nanoapi.ai/docs/webhooks). | A missing or unknown `model` returns `400 invalid_request` with `param: "model"`, and so does any parameter the model doesn't accept. Every error is listed under [Errors](https://nanoapi.ai/docs/tasks/create#errors) below. ## Request example Using [Genjutsu 1.0](https://nanoapi.ai/docs/genjutsu)'s motion transfer as an example: **cURL** ```bash curl https://api.nanoapi.ai/v1/tasks \ -H "Authorization: Bearer $NANOAPI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "genjutsu-1.0", "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "resolution": "720p", "webhook_url": "https://example.com/hooks/nanoapi" }' ``` **Python** ```python import os import requests response = requests.post( "https://api.nanoapi.ai/v1/tasks", headers={"Authorization": f"Bearer {os.environ['NANOAPI_API_KEY']}"}, json={ "model": "genjutsu-1.0", "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "resolution": "720p", "webhook_url": "https://example.com/hooks/nanoapi", }, timeout=30, ) body = response.json() print(body["code"], body.get("data") or body["error"]) ``` **Node.js** ```js const response = await fetch('https://api.nanoapi.ai/v1/tasks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NANOAPI_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'genjutsu-1.0', mode: 'motion_control', image_urls: ['https://example.com/person.png'], video_url: 'https://example.com/dance.mp4', resolution: '720p', webhook_url: 'https://example.com/hooks/nanoapi', }), }); const body = await response.json(); console.log(body.code, body.data ?? body.error); ``` ## Response On success you get `200` with the task in short form as `data`, with only these fields. Get the parameters, results, duration and the rest by [retrieving the task](https://nanoapi.ai/docs/tasks/get). ```json { "code": 200, "message": "success", "data": { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "queued", "billing": { "currency": "USD", "frozen": "4.095", "charged": "0.00", "refunded": "0.00", "billed_seconds": null }, "error": null, "created_at": "2026-10-22T08:30:00Z" } } ``` | `data.status` | Meaning | Next | |---|---|---| | `queued` | All good: the task is queued and `billing.frozen` is the amount frozen. | Keep `data.id`; wait for the [webhook](https://nanoapi.ai/docs/webhooks) or [retrieve the task](https://nanoapi.ai/docs/tasks/get). | | `failed` | The task was created but rejected as soon as it was submitted to the generation service (usually `content_policy`). The frozen amount has been returned; `error` has the reason. | Handle it by `data.error.type`, see [Failure reasons](https://nanoapi.ai/docs/tasks/get#task-failures). | ## Errors A failed request returns 4xx / 5xx: **no task was created and nothing was frozen.** For example, not enough balance: ```json { "code": 402, "message": "Your available balance is not enough to create this task.", "error": { "type": "insufficient_balance", "param": null, "details": { "required": "4.095", "available": "3.00" } }, "request_id": "rq_01jb8z9x2c4v6b8n0m1q3w5e7r" } ``` Branch on `error.type`; `error.param` names the parameter at fault and the fields of `error.details` are listed below. | `error.type` | Status | When | What to do | |---|---|---|---| | `invalid_request` | 400 | A parameter is wrong: `model` is missing or doesn't exist (`param` is `"model"`); a required parameter is missing or has the wrong type or value, for example `image_urls` isn't 1–10 images or `resolution` isn't `480p` / `720p` / `1080p`; or you sent a parameter the model doesn't accept. `param` names the parameter. | Fix it using `param` and `message`. Each model's parameters are on its page, for example [Genjutsu 1.0](https://nanoapi.ai/docs/genjutsu#create). | | `invalid_media` | 400 | The source video can't be read: the link doesn't open or has expired, isn't a public address, isn't a video, or is in an unsupported format (mp4, mov, webm and mkv are supported). `details.reason` says why (in English). | Use a different, directly downloadable video URL. | | `video_too_long` | 400 | The source video is longer than 30 seconds. `details.video_duration` is its length in seconds, `details.max_seconds` the limit. | Trim it to 30 seconds or less and create the task again. | | `video_too_short` | 400 | The `replace_object` source video is shorter than 4 seconds. `details.video_duration` is its length in seconds, `details.min_seconds` the minimum. | Use a video of at least 4 seconds. | | `invalid_api_key` | 401 | The key is missing or malformed, doesn't exist, was deleted or has been reset. | Use a valid key, see [Authentication](https://nanoapi.ai/docs/conventions#authentication). | | `insufficient_balance` | 402 | Your available balance can't cover the amount to freeze. `details.required` is what this task needs, `details.available` your available balance; `details.required` is `null` when your available balance is 0. | [Top up](https://nanoapi.ai/console/recharge), then create the task again. | | `account_suspended` | 403 | Your account has been suspended. | Contact support@nanoapi.ai. | | `rate_limited` | 429 | Too many creates: at most 20 tasks per 10 seconds per account, shared by all your keys (the website playground counts too). There's no limit on tasks in progress. | Retry after `Retry-After` seconds. | | `internal_error` | 500 | Something went wrong on our side. | Yes, retry later. | | `service_unavailable` | 503 | The price can't be calculated right now. No task was created. | Retry after `Retry-After` seconds (usually 10). | If the source video can't be opened or is too long or too short, the create endpoint returns one of the `400`s above: no task is created and nothing is frozen. Reference images are only read when generation runs: if one of them is a problem, the task is created and fails later with `invalid_media`, see [Failure reasons](https://nanoapi.ai/docs/tasks/get#task-failures). --- # Retrieve a task Source: https://nanoapi.ai/docs/tasks/get Get one task's status and result: response, errors, the task object and failure reasons. ``` GET https://api.nanoapi.ai/v1/tasks/{task_id} ``` Get one task's status and result. A task is generated in the background; with a [webhook](https://nanoapi.ai/docs/webhooks) you don't need to poll, and the webhook body is exactly this endpoint's response. Every response uses the `{ code, message, data }` envelope, see [Response format](https://nanoapi.ai/docs/conventions#responses). ```bash curl https://api.nanoapi.ai/v1/tasks/tk_01jb8z5q4w3e2r1t0y9u8i7o6p \ -H "Authorization: Bearer $NANOAPI_API_KEY" ``` ## Response On success you get `200` with the full [task object](https://nanoapi.ai/docs/tasks/get#task-object) as `data`: ```json { "code": 200, "message": "success", "data": { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "running", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [], "billing": { "currency": "USD", "frozen": "4.095", "charged": "0.00", "refunded": "0.00", "billed_seconds": null }, "error": null, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": "2026-10-22T08:31:05Z", "finished_at": null, "elapsed_seconds": null } } ``` This task is still generating: `outputs` is empty, `finished_at` and `elapsed_seconds` are `null` and nothing has been charged yet. A succeeded task is shown under [The task object](https://nanoapi.ai/docs/tasks/get#task-object), a failed one under [Failed tasks](https://nanoapi.ai/docs/tasks/get#failed-task). | `data.status` | Meaning | Next | |---|---|---| | `queued` / `running` | Queued / generating. The response has `Retry-After: 15`, the suggested polling interval. | Check again in 15 seconds or more. | | `succeeded` | Done: `data.outputs` has the results and `data.billing.charged` is what was charged. | Download the result, see [Outputs](https://nanoapi.ai/docs/tasks/get#outputs). | | `failed` | **The task failed; this request didn't.** The frozen amount has been returned in full. | Handle it by `data.error.type`, see [Failed tasks](https://nanoapi.ai/docs/tasks/get#failed-task). | ## Errors A failed request returns 4xx / 5xx. Only this one read failed; it says nothing about the task, which keeps queuing or generating as before. For example, a task that doesn't exist: ```json { "code": 404, "message": "Task not found.", "error": { "type": "not_found", "param": null, "details": null }, "request_id": "rq_01jb8z9x2c4v6b8n0m1q3w5e7r" } ``` | `error.type` | Status | When | What to do | |---|---|---|---| | `invalid_request` | 400 | The task ID is longer than 64 characters. | Check the task ID. | | `invalid_api_key` | 401 | The key is missing or malformed, doesn't exist, was deleted or has been reset. | Use a valid key. | | `account_suspended` | 403 | Your account has been suspended. | Contact support@nanoapi.ai. | | `not_found` | 404 | The task doesn't exist or isn't yours. | Check the task ID. A task can only be read by the account that created it (with any of its keys). | | `rate_limited` | 429 | Too many reads: all read endpoints together (retrieving and listing tasks, the balance, products) allow 60 requests per 10 seconds per account, shared by all your keys (the website playground counts too). | Retry after `Retry-After` seconds. Poll one task at most every 15 seconds, check several with the `ids` of [List tasks](https://nanoapi.ai/docs/tasks/list) in one request, or use [webhooks](https://nanoapi.ai/docs/webhooks) instead. | | `internal_error` | 500 | Something went wrong on our side. | Reads have no side effects, so you can simply retry. | ## The task object It's the `data` of a retrieved task, each item in a task list's `data.items`, and the `data` of a [webhook](https://nanoapi.ai/docs/webhooks) (creating a task returns a short form). This is what `data` contains: ```json { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "succeeded", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [ { "index": 0, "type": "video", "url": "https://file.example.com/outputs/tk_01jb8z5q4w3e2r1t0y9u8i7o6p/0.mp4", "preview_url": "https://file.example.com/outputs/tk_01jb8z5q4w3e2r1t0y9u8i7o6p/0_compressed.mp4" } ], "billing": { "currency": "USD", "frozen": "4.095", "charged": "4.095", "refunded": "0.00", "billed_seconds": 15 }, "error": null, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": "2026-10-22T08:31:05Z", "finished_at": "2026-10-22T08:47:12Z", "elapsed_seconds": 1032 } ``` | Field | Description | |---|---| | `id` | Task ID, such as `tk_01jb8z5q4w3e2r1t0y9u8i7o6p`. See [IDs](https://nanoapi.ai/docs/conventions#ids). | | `model` | The model version, such as `genjutsu-1.0`. | | `status` | `queued`, `running`, `succeeded` or `failed`. See [Status](https://nanoapi.ai/docs/tasks/get#status). | | `input` | The model parameters from the create request. | | `num_outputs` | The number of results requested. | | `outputs` | The results, empty until the task succeeds. See [Outputs](https://nanoapi.ai/docs/tasks/get#outputs). | | `billing` | Amounts in US dollars. See [Billing fields](https://nanoapi.ai/docs/tasks/get#billing). | | `error` | Why the task failed (`{ type, message }`), otherwise `null`. See [Failed tasks](https://nanoapi.ai/docs/tasks/get#failed-task). | | `webhook_url` | From the create request, or `null`. | | `created_at` / `started_at` / `finished_at` | ISO 8601 times; `started_at` and `finished_at` are `null` until they happen. | | `elapsed_seconds` | Duration in seconds, from creation to the end, queueing included; `null` until the task finishes. | ### Status ``` queued ──starts──→ running ──┬─→ succeeded (charged) │ └─→ failed (frozen amount returned) └──not started within 30 minutes──→ failed (queue_timeout) ``` `succeeded` and `failed` are final and never change. Tasks can't be cancelled. ### Outputs | Field | Description | |---|---| | `index` | Position, from 0. | | `type` | `video` for Genjutsu. | | `url` | The result file, as a direct download link. | | `preview_url` | A silent, compressed copy 600 pixels wide, for previews; `null` if it couldn't be made. | Anyone with the link can open the file, so don't share links you want to keep private. Result files currently aren't deleted, but copy anything you need to keep to your own storage. ### Billing fields | Field | Description | |---|---| | `frozen` | Frozen from your balance when the task was created. | | `charged` | Charged when the task succeeded; `"0.00"` until it finishes and for failed tasks. | | `refunded` | Returned to your available balance; `"0.00"` until it finishes. | | `billed_seconds` | Seconds billed (a number, up to 3 decimals) once the task succeeds, otherwise `null`. | Once the task has finished, `charged + refunded = frozen`. See [Billing](https://nanoapi.ai/docs/conventions#billing). ## Failed tasks A failed task was created and its cost frozen, but no result was generated. A failed task: - has `status: "failed"`. That's final: **it never changes**, and you can't retry the task itself. - has an `error` with the reason: `type` for your program, `message` an English explanation for people. A task failure isn't a failed request, so there's no HTTP status here. - has an empty `outputs` array. - **got its money back in full**: `billing.charged` is `"0.00"` and `billing.refunded` equals `billing.frozen`. The frozen amount is already back in your available balance; you don't need to do anything. A failed task looks like this in the `data` of a retrieved task, in a task list's `data.items` and in the `data` of a webhook. Here are three tasks that failed at different stages; they differ in `error`, `started_at` and when they finished: **Failed during generation** Generation started, then a reference image couldn't be downloaded (`invalid_media`). `started_at` is set. ```json { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "failed", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [], "billing": { "currency": "USD", "frozen": "4.095", "charged": "0.00", "refunded": "4.095", "billed_seconds": null }, "error": { "type": "invalid_media", "message": "A reference image could not be downloaded. Make sure every link in image_urls is publicly downloadable." }, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": "2026-10-22T08:31:05Z", "finished_at": "2026-10-22T08:31:40Z", "elapsed_seconds": 100 } ``` **Rejected on submission** The content didn't pass moderation, so the generation service turned the task down when it was created (`content_policy`). The create endpoint returns the task (short form, see [Create a task → Response](https://nanoapi.ai/docs/tasks/create#response)) already `failed`; `started_at` is `null` and it finished within seconds. ```json { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "failed", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [], "billing": { "currency": "USD", "frozen": "4.095", "charged": "0.00", "refunded": "4.095", "billed_seconds": null }, "error": { "type": "content_policy", "message": "The input was rejected by the content policy." }, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": null, "finished_at": "2026-10-22T08:30:03Z", "elapsed_seconds": 3 } ``` **Queue timeout** It didn't start within 30 minutes of being queued (`queue_timeout`). `started_at` is `null` and it finished exactly 30 minutes after it was created. ```json { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "failed", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [], "billing": { "currency": "USD", "frozen": "4.095", "charged": "0.00", "refunded": "4.095", "billed_seconds": null }, "error": { "type": "queue_timeout", "message": "The task did not start within 30 minutes." }, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": null, "finished_at": "2026-10-22T09:00:00Z", "elapsed_seconds": 1800 } ``` Compared with a succeeded task, these fields differ: | Field | Failed task | Description | |---|---|---| | `status` | `"failed"` | Final, never changes. | | `error` | `{ type, message }` | `type` is the reason; branch on it. `message` is an English explanation. Every reason and what to do: [Failure reasons](https://nanoapi.ai/docs/tasks/get#task-failures). | | `outputs` | `[]` | No result. | | `billing.charged` | `"0.00"` | Nothing was charged. | | `billing.refunded` | Equal to `billing.frozen` | The frozen amount went back to your available balance. | | `billing.billed_seconds` | `null` | Only set on succeeded tasks. | | `started_at` | A time or `null` | `null` means it failed before generation started: rejected on submission, or a queue timeout. | | `finished_at` / `elapsed_seconds` | Set | When it failed / seconds from creation to failure. | A failed task can't be retried. To generate again, [create a task](https://nanoapi.ai/docs/tasks/create) with the same parameters: you get a new task ID and the cost is frozen again. ## Failure reasons | `error.type` | Cause | What to do | Resubmitting unchanged | |---|---|---|---| | `content_policy` | The content breaks the usage policy: the reference images, source video or prompt contain inappropriate (NSFW) content, or a protected likeness or copyrighted content (such as a photo of a public figure). | Use different media or a different prompt. | Fails again | | `invalid_media` | The media can't be used: a reference image or the source video can't be downloaded, an image is corrupt or in an unsupported format, or the video length isn't allowed. `message` says which. | Replace the media `message` points to. | Fails again | | `invalid_request` | The generation service didn't accept these parameters. `message` may name the parameter. | Adjust the parameters as `message` says. | Fails again | | `generation_failed` | Generation failed or timed out. | Create it again later; if it keeps failing, change the media or prompt. | May succeed | | `queue_timeout` | Not started within 30 minutes of being queued, usually at peak times. | Create it again later. | May succeed | | `internal_error` | An internal error on our side. | Create it again later. | May succeed | New failure reasons may be added: **treat any `type` you don't recognize as an ordinary failure** instead of raising an error. ### When a task can fail | Stage | Possible `error.type` | `started_at` | Where you see it | |---|---|---|---| | Rejected on submission: the create endpoint hands the task to the generation service, which turns it down | `content_policy`, `invalid_media`, `invalid_request`, `internal_error` | `null` | The create endpoint returns `200` with a task that is already `failed`; the same task when you read it and in the webhook | | Queue timeout: not started within 30 minutes of being queued | `queue_timeout` | `null` | Reads, webhook | | During generation | `content_policy`, `invalid_media`, `generation_failed`, `internal_error` | Set | Reads, webhook | ### Messages `message` is an English explanation for developers: log it, use it to debug, or translate it for your own users. These are all the messages you can currently get: | `error.type` | `message` | Meaning | |---|---|---| | `content_policy` | The input was rejected by the content policy. | The content didn't pass moderation. | | `invalid_media` | A reference image could not be downloaded. Make sure every link in image_urls is publicly downloadable. | A reference image can't be downloaded. | | `invalid_media` | A reference image could not be read. It may be corrupted or in an unsupported format. | A reference image is corrupt or in an unsupported format. | | `invalid_media` | The source video could not be downloaded. Make sure video_url is publicly downloadable. | The source video can't be downloaded. | | `invalid_media` | The source video must be 4-30 seconds long. | The source video length isn't allowed. | | `invalid_media` | A required input file is missing or could not be used. | A required file is missing. | | `invalid_media` | An input file could not be used. Check image_urls and video_url. | Another media problem. | | `invalid_request` | The value of <parameter> was rejected. | One parameter's value was rejected, for example `The value of resolution was rejected.` | | `invalid_request` | The task parameters were rejected. | The parameters were rejected; which one is unknown. | | `generation_failed` | The generation failed. | Generation failed. | | `generation_failed` | The generation timed out. | Generation timed out. | | `generation_failed` | The task did not finish in time. | Generation never finished. | | `queue_timeout` | The task did not start within 30 minutes. | Queue timeout. | | `internal_error` | An internal error occurred. Please try again later. | Internal error. | The wording may change and new messages may be added: **branch on `type` only.** --- # List tasks Source: https://nanoapi.ai/docs/tasks/list Check several tasks at once, or filter them by status and creation time: parameters, response and errors. ``` GET https://api.nanoapi.ai/v1/tasks ``` Check several tasks at once (up to 20), or filter them by status and creation time. Every response uses the `{ code, message, data }` envelope, see [Response format](https://nanoapi.ai/docs/conventions#responses). ## Parameters | Query parameter | Description | |---|---| | `ids` | Up to 20 comma-separated task IDs, to check several tasks in one request. Other filters and pagination are then ignored. | | `status` | `queued`, `running`, `succeeded` or `failed`. | | `created_after` / `created_before` | Creation time range (ISO 8601). | | `limit` / `starting_after` | See [Pagination](https://nanoapi.ai/docs/conventions#pagination). | ```bash curl "https://api.nanoapi.ai/v1/tasks?ids=tk_01jb8z5q4w3e2r1t0y9u8i7o6p,tk_01jb8z7c2d4f6g8h0j2k4m6n8p" \ -H "Authorization: Bearer $NANOAPI_API_KEY" ``` ## Response On success you get `200` with a list as `data`: ```json { "code": 200, "message": "success", "data": { "object": "list", "items": [ { "object": "task", "id": "tk_01jb8z7c2d4f6g8h0j2k4m6n8p", "model": "genjutsu-1.0", "status": "running", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/walk.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [], "billing": { "currency": "USD", "frozen": "2.73", "charged": "0.00", "refunded": "0.00", "billed_seconds": null }, "error": null, "webhook_url": null, "created_at": "2026-10-22T08:40:00Z", "started_at": "2026-10-22T08:41:10Z", "finished_at": null, "elapsed_seconds": null }, { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "succeeded", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [ { "index": 0, "type": "video", "url": "https://file.example.com/outputs/tk_01jb8z5q4w3e2r1t0y9u8i7o6p/0.mp4", "preview_url": "https://file.example.com/outputs/tk_01jb8z5q4w3e2r1t0y9u8i7o6p/0_compressed.mp4" } ], "billing": { "currency": "USD", "frozen": "4.095", "charged": "4.095", "refunded": "0.00", "billed_seconds": 15 }, "error": null, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": "2026-10-22T08:31:05Z", "finished_at": "2026-10-22T08:47:12Z", "elapsed_seconds": 1032 } ], "has_more": false } } ``` - Every item in `items` is a full [task object](https://nanoapi.ai/docs/tasks/get#task-object), newest first. Match results by `id`, not by position. - `has_more` is `true` when there's another page, see [Pagination](https://nanoapi.ai/docs/conventions#pagination). - IDs in `ids` that don't exist or aren't yours are left out, and an unknown `starting_after` returns an empty list: neither returns `404`. - Reads are limited to 60 per 10 seconds, so check all the tasks you're waiting on in one request ([Rate limits](https://nanoapi.ai/docs/conventions#rate-limits)). ## Errors A failed request returns 4xx / 5xx. Only this one read failed; it says nothing about the tasks. For example, more than 20 `ids`: ```json { "code": 400, "message": "ids must contain 1 to 20 task ids.", "error": { "type": "invalid_request", "param": "ids", "details": null }, "request_id": "rq_01jb8z9x2c4v6b8n0m1q3w5e7r" } ``` Branch on `error.type`; `error.param` names the parameter at fault. | `error.type` | Status | When | What to do | |---|---|---|---| | `invalid_request` | 400 | A parameter is wrong: `limit` isn't 1–100; `ids` is empty or has more than 20 IDs (`param` is `"ids"`); `status` isn't `queued` / `running` / `succeeded` / `failed`; `created_after` / `created_before` isn't an ISO 8601 time with a time zone. | Fix the parameter named in `param`. | | `invalid_api_key` | 401 | The key is missing or malformed, doesn't exist, was deleted or has been reset. | Use a valid key. | | `account_suspended` | 403 | Your account has been suspended. | Contact support@nanoapi.ai. | | `rate_limited` | 429 | Too many reads: all read endpoints together (retrieving and listing tasks, the balance, products) allow 60 requests per 10 seconds per account, shared by all your keys (the website playground counts too). | Retry after `Retry-After` seconds. Pages count as reads too, so don't page through quickly. | | `internal_error` | 500 | Something went wrong on our side. | Reads have no side effects, so you can simply retry. | --- # Authentication & conventions Source: https://nanoapi.ai/docs/conventions Shared by every endpoint: authentication, request and response format, rate limits, pagination, billing, amounts and IDs. ## Authentication Create a key in [Console → API keys](https://nanoapi.ai/console/api-keys) (it starts with `nf_`) and send it with every request: ``` Authorization: Bearer nf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` - **Server side only**: a key can spend your balance. Never put it in front-end code, mobile apps or public repositories. - **The full key is shown once**, when you create or reset it. - **Resetting a key invalidates the old one immediately** (`401 invalid_api_key`). To switch without downtime, create a new key, deploy it, then delete the old one. - You can have several keys, for example one per environment. They all use the same balance and share the [rate limits](https://nanoapi.ai/docs/conventions#rate-limits). - Keys, the webhook signing secret, top-ups, billing and usage are managed only in the [console](https://nanoapi.ai/console), with no API. ## Request format | Item | Convention | |---|---| | Base URL | `https://api.nanoapi.ai/v1` | | Format | HTTPS only. JSON in UTF-8; requests with a body send `Content-Type: application/json`. | | Times | ISO 8601 in UTC, such as `2026-10-22T08:30:00Z`. `X-RateLimit-Reset` and `webhook-timestamp` use Unix seconds. | | Versioning | In the path (`/v1`). New fields don't change the version: **ignore fields you don't recognize**. Unknown request parameters return `400 invalid_request`. | | Request ID | Every response has an `X-Request-Id` header (same as `request_id` in error responses). Log it and include it when you contact support. | ## Response format Every response uses the same envelope, so `code` tells you right away whether the call worked. On success the HTTP status is `200` and the result is in `data`: ```json { "code": 200, "message": "success", "data": { "object": "balance", "currency": "USD", "available": "12.50", "frozen": "1.50" } } ``` On failure the HTTP status is 4xx / 5xx, `code` repeats it and there's no `data`: ```json { "code": 402, "message": "Your available balance is not enough to create this task.", "error": { "type": "insufficient_balance", "param": null, "details": { "required": "4.095", "available": "3.00" } }, "request_id": "rq_01jb8z9x2c4v6b8n0m1q3w5e7r" } ``` | Field | Description | |---|---| | `code` | The HTTP status, the same as the response's. | | `message` | An English explanation. The wording may change; don't match on it. | | `error.type` | The error code. **Branch on this**: one HTTP status can carry different types that need different handling (for example `400` can be `invalid_request`, `invalid_media`, `video_too_long` and more). | | `error.param` | The parameter that caused the error, such as `"model"`, or `null`. | | `error.details` | Extra information, such as `required` / `available` when the balance is too low, or `null`. | | `request_id` | Same as the `X-Request-Id` header. Include it when you contact us. | The errors each endpoint can return, and what to do about them, are listed under the endpoint: [create a task](https://nanoapi.ai/docs/tasks/create#errors), [retrieve a task](https://nanoapi.ai/docs/tasks/get#errors), [list tasks](https://nanoapi.ai/docs/tasks/list#errors), [balance](https://nanoapi.ai/docs/balance#errors), [products](https://nanoapi.ai/docs/products#errors). In general: - `4xx`: fix the request as the table says. **Sending it again unchanged fails again** (except `429`: just wait). - `429` and `503`: the response has a `Retry-After` header (seconds). Wait that long, then retry. - New error codes may be added. Handle an `error.type` you don't recognize by its status class (`4xx`: something is wrong with the request, `5xx`: a problem on our side) instead of raising an error. - A wrong path (for example without `/v1`) returns `404 not_found`; a method the endpoint doesn't support (for example `DELETE /v1/tasks/{task_id}`: tasks can't be cancelled) returns `405 method_not_allowed`. A failed task (`data.status` is `failed`) isn't a failed request: the request succeeded (`200`) and the reason is in the task's `error.type`, see [Failed tasks](https://nanoapi.ai/docs/tasks/get#failed-task). [Webhook](https://nanoapi.ai/docs/webhooks) requests use this format too: the body is exactly what fetching the task returns, so the same code parses both. ## Rate limits Per **user**: all your keys share the same allowance (the playground on the website counts too), and creates and reads are counted separately. Only the request rate is limited; there's no limit on how many tasks can be in progress at once. | Endpoints | Limit | When exceeded | |---|---|---| | Creating tasks | **20 per 10 seconds** | `429 rate_limited`: wait `Retry-After` seconds | | Reads (every `GET`) | **60 per 10 seconds** | `429 rate_limited`: wait `Retry-After` seconds | Windows are fixed 10-second slots (starting on whole multiples of 10 seconds of Unix time). The `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers give the current window's total, what's left and when it resets (Unix seconds). To stay within the limits: - Use [webhooks](https://nanoapi.ai/docs/webhooks) instead of polling. - Poll each task **at most once every 15 seconds** (in-progress tasks return `Retry-After: 15`). - Check several tasks in one request with `GET /v1/tasks?ids=…` (up to 20). See [List tasks](https://nanoapi.ai/docs/tasks/list). ## Pagination List endpoints use cursor pagination. Their `data` is `{ "object": "list", "items": [...], "has_more": true }`. | Parameter | Description | |---|---| | `limit` | Items per page. Default 20, maximum 100. | | `starting_after` | The `id` of the last item on the previous page. | Stop when `has_more` is `false`. Page requests are reads and count toward the [read limit](https://nanoapi.ai/docs/conventions#rate-limits). ## Billing You pay from a prepaid US dollar balance that never expires and can't go negative. Each model's price is on its page, for example [Genjutsu 1.0 pricing](https://nanoapi.ai/docs/genjutsu#pricing). | When | What happens to your balance | |---|---| | You create a task | The cost is **frozen**. If your available balance is too low, you get `402 insufficient_balance` and no task is created. | | The task succeeds | The cost is **charged**, never more than the frozen amount; anything left over is returned. | | The task fails | The frozen amount is **returned in full**. | - Frozen money can't be spent on other tasks until its task finishes. - The task's [`billing`](https://nanoapi.ai/docs/tasks/get#billing) field shows the frozen, charged and refunded amounts. Once the task has finished, `charged + refunded = frozen`. - **Rely on the create response to know whether your balance is enough**, not on checking the balance first: if it isn't, creating returns `402` and no task is created. - [Top up](https://nanoapi.ai/console/recharge) and see your [billing history](https://nanoapi.ai/console/billing) in the console; read the balance with [`GET /v1/balance`](https://nanoapi.ai/docs/balance). ## Amounts US dollars as **decimal strings** with up to 6 decimal places, such as `"4.095"`. **Don't parse them as floats**: use `decimal.Decimal` in Python or a decimal library in JavaScript. `billing.billed_seconds` is a number, not a string. ## IDs Lowercase, with a type prefix: task `tk_`, webhook event `ev_` (the webhook's `webhook-id`), request `rq_`. **Treat them as opaque strings** and send them back exactly as received: don't parse the prefix or change the case. --- # Webhooks Source: https://nanoapi.ai/docs/webhooks Get notified at your HTTPS endpoint when a task succeeds or fails. Set `webhook_url` when you [create a task](https://nanoapi.ai/docs/tasks/create) and NanoAPI sends a `POST` to it when the task finishes. The body is exactly what [retrieving the task](https://nanoapi.ai/docs/tasks/get) returns, so the same code parses both. The format follows [Standard Webhooks](https://www.standardwebhooks.com/); [verifying signatures](https://nanoapi.ai/docs/webhooks#verify-signatures) is optional. ## When it's sent Once per task, **when it finishes**: it succeeded (`data.status` is `succeeded`) or failed (`data.status` is `failed`, including tasks rejected on submission or timed out in the queue). Nothing is sent when generation starts; to show progress, read `status` with [`GET /v1/tasks/{id}`](https://nanoapi.ai/docs/tasks/get). ## Request format ``` POST Content-Type: application/json webhook-id: ev_01jb9a3k7m2n5p8q4r6s1t0v9w webhook-timestamp: 1792658832 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= User-Agent: Nanofield-Webhooks/1.0 ``` **Succeeded** `data.outputs` has the result files and `data.billing.charged` is what was charged. ```json { "code": 200, "message": "success", "data": { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "succeeded", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [ { "index": 0, "type": "video", "url": "https://file.example.com/outputs/tk_01jb8z5q4w3e2r1t0y9u8i7o6p/0.mp4", "preview_url": "https://file.example.com/outputs/tk_01jb8z5q4w3e2r1t0y9u8i7o6p/0_compressed.mp4" } ], "billing": { "currency": "USD", "frozen": "4.095", "charged": "4.095", "refunded": "0.00", "billed_seconds": 15 }, "error": null, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": "2026-10-22T08:31:05Z", "finished_at": "2026-10-22T08:47:12Z", "elapsed_seconds": 1032 } } ``` **Failed** `data.outputs` is empty, `data.error.type` is the reason (see [Failure reasons](https://nanoapi.ai/docs/tasks/get#task-failures)) and the frozen amount was returned in full (`billing.refunded` equals `billing.frozen`). Tasks rejected on submission or timed out in the queue look the same. ```json { "code": 200, "message": "success", "data": { "object": "task", "id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p", "model": "genjutsu-1.0", "status": "failed", "input": { "mode": "motion_control", "image_urls": ["https://example.com/person.png"], "video_url": "https://example.com/dance.mp4", "prompt": "", "resolution": "720p" }, "num_outputs": 1, "outputs": [], "billing": { "currency": "USD", "frozen": "4.095", "charged": "0.00", "refunded": "4.095", "billed_seconds": null }, "error": { "type": "invalid_media", "message": "A reference image could not be downloaded. Make sure every link in image_urls is publicly downloadable." }, "webhook_url": "https://example.com/hooks/nanoapi", "created_at": "2026-10-22T08:30:00Z", "started_at": "2026-10-22T08:31:05Z", "finished_at": "2026-10-22T08:31:40Z", "elapsed_seconds": 100 } } ``` **Task not found** **A webhook never looks like this**: webhooks are sent only when a task finishes, so the task always exists. You get this HTTP `404` only from [`GET /v1/tasks/{id}`](https://nanoapi.ai/docs/tasks/get), when the task ID doesn't exist or isn't yours. If you parse webhooks and fetched tasks with the same code, check `code` first: anything other than `200` means read `error.type`, and there's no `data`. ```json { "code": 404, "message": "Task not found.", "error": { "type": "not_found", "param": null, "details": null }, "request_id": "rq_01jb8z9x2c4v6b8n0m1q3w5e7r" } ``` | Field or header | Description | |---|---| | `code` | Always `200`: this is a normal notification. **It doesn't mean the task succeeded.** | | `message` | Always `success`. | | `data` | The **full [task object](https://nanoapi.ai/docs/tasks/get#task-object)** when the task finished. No need to fetch it again. **Whether it succeeded is `data.status`.** | | `webhook-id` | Event ID. **Unchanged across retries** (so is the body): deduplicate on it. | | `webhook-timestamp` | When this attempt was sent (Unix seconds). When the task finished is `data.finished_at`. | | `webhook-signature` | The signature, see [Verify signatures](https://nanoapi.ai/docs/webhooks#verify-signatures). | ## Delivery and retries - **A `2xx` within 15 seconds** counts as delivered. Other statuses (including `3xx`; redirects aren't followed), timeouts and connection errors count as undelivered. - An undelivered webhook is retried after 1 minute, 5 minutes and 30 minutes, then NanoAPI gives up. - The same webhook **can arrive more than once**. Handling a webhook: 1. Optionally [verify the signature](https://nanoapi.ai/docs/webhooks#verify-signatures). 2. **Respond `2xx` right away** and process afterwards. 3. Skip any `webhook-id` you've already processed. 4. Branch on `data.status`: `succeeded`, take the result from `data.outputs`; `failed`, read `data.error.type` (the frozen amount is already back in your balance). Fallback: if nothing has arrived 60 minutes after you created the task, fetch it once with `GET /v1/tasks/{id}`. ## URL requirements - `https` only, at most 2048 characters. - No private, loopback or reserved addresses. The hostname is resolved before every attempt; if it resolves to a non-public address, the attempt isn't sent and counts as undelivered. ## Verify signatures (optional) Anyone who knows your webhook URL can send fake webhooks. Verify signatures when a webhook triggers something important, such as shipping or charging for an order. **The signing secret** starts with `whsec_`; there's one per user, viewed and reset in [Console → Webhooks](https://nanoapi.ai/console/webhooks). After a reset the old secret **stays valid for 24 hours** and webhooks carry both signatures: deploy the new secret, then retire the old one. Resetting again within those 24 hours revokes the oldest secret immediately. **Algorithm**: 1. Signed content: `{webhook-id}.{webhook-timestamp}.{raw request body}`. 2. Key: the part of the secret after `whsec_`, base64-decoded. 3. HMAC-SHA256 of the signed content, base64-encoded. 4. `webhook-signature` can hold several space-separated signatures (such as `v1,xxx v1,yyy`); the request is valid if **any** `v1` signature matches. Compare in constant time. 5. Reject requests whose `webhook-timestamp` is more than 5 minutes from the current time. > **Warning:** **Verify the raw request body** exactly as received. Parsing and re-serializing the JSON can change the bytes, and the signature won't match. **Python** ```python title="verify_webhook.py" import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, body: bytes) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] if abs(time.time() - int(timestamp)) > 300: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for item in headers["webhook-signature"].split(" "): version, _, signature = item.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return True return False ``` Standard library only (Python 3.9+). In Flask, pass `request.headers` and `request.get_data()` (the raw body). **Node.js** ```js title="verify-webhook.mjs" import { createHmac, timingSafeEqual } from 'node:crypto'; export function verifyWebhook(secret, headers, rawBody) { const msgId = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature']; if (!msgId || !timestamp || !signatures) return false; const seconds = Number(timestamp); if (!Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) { return false; } const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = Buffer.from( createHmac('sha256', key) .update(`${msgId}.${timestamp}.`) .update(rawBody) .digest('base64') ); return signatures.split(' ').some((item) => { const [version, signature = ''] = item.split(',', 2); const received = Buffer.from(signature); return ( version === 'v1' && received.length === expected.length && timingSafeEqual(received, expected) ); }); } ``` Only `node:crypto`. `headers` has lowercase names, like Node's `req.headers`. In Express, add `express.raw({ type: 'application/json' })` to the route so `req.body` is the raw body; with a Fetch API `Request`, pass `Object.fromEntries(request.headers)` and `await request.text()`. --- # Balance Source: https://nanoapi.ai/docs/balance Your available balance and the amount frozen for tasks in progress. ``` GET https://api.nanoapi.ai/v1/balance ``` ```bash curl https://api.nanoapi.ai/v1/balance \ -H "Authorization: Bearer $NANOAPI_API_KEY" ``` **Response: `200 OK`** ```json { "code": 200, "message": "success", "data": { "object": "balance", "currency": "USD", "available": "12.50", "frozen": "1.50" } } ``` Fields of `data`: | Field | Description | |---|---| | `object` | Always `balance`. | | `currency` | Always `USD`. | | `available` | Available for new tasks. | | `frozen` | Frozen for tasks in progress. | Both are [decimal strings](https://nanoapi.ai/docs/conventions#amounts). The balance can't go negative and doesn't expire. How tasks freeze and charge it: [Billing](https://nanoapi.ai/docs/conventions#billing). API keys, recharges, billing history and usage are console-only: [API keys](https://nanoapi.ai/console/api-keys), [Recharge](https://nanoapi.ai/console/recharge), [Billing](https://nanoapi.ai/console/billing), [Usage](https://nanoapi.ai/console/usage). ## Errors A failed request returns 4xx / 5xx (fields in [Response format](https://nanoapi.ai/docs/conventions#responses)). For example, an invalid key: ```json { "code": 401, "message": "The API key is invalid, deleted, or was reset.", "error": { "type": "invalid_api_key", "param": null, "details": null }, "request_id": "rq_01jb8z9x2c4v6b8n0m1q3w5e7r" } ``` | `error.type` | Status | When | What to do | |---|---|---|---| | `invalid_api_key` | 401 | The key is missing or malformed, doesn't exist, was deleted or has been reset. | Use a valid key, see [Authentication](https://nanoapi.ai/docs/conventions#authentication). | | `account_suspended` | 403 | Your account has been suspended. | Contact support@nanoapi.ai. | | `rate_limited` | 429 | Too many reads: all read endpoints together (retrieving and listing tasks, the balance, products) allow 60 requests per 10 seconds per account, shared by all your keys (the website playground counts too). | Retry after `Retry-After` seconds. There's no need to check the balance before creating a task: if it's too low, creating returns `402 insufficient_balance`. | | `internal_error` | 500 | Something went wrong on our side. | Reads have no side effects, so you can simply retry. | --- # Products Source: https://nanoapi.ai/docs/products Available products with their modes, per-second prices and limits. ``` GET https://api.nanoapi.ai/v1/products ``` ```bash curl https://api.nanoapi.ai/v1/products \ -H "Authorization: Bearer $NANOAPI_API_KEY" ``` **Response: `200 OK`**, with the products in `data.items`: ```json { "code": 200, "message": "success", "data": { "object": "list", "items": [ { "id": "genjutsu", "object": "product", "name": "Genjutsu", "description": "Generate a new video from a source video and reference images.", "models": ["genjutsu-1.0"], "modes": ["motion_control", "replace_object"], "output_type": "video", "max_outputs": 1, "billing_unit": "second", "currency": "USD", "prices": [ { "resolution": "480p", "unit_price": "0.117" }, { "resolution": "720p", "unit_price": "0.273" }, { "resolution": "1080p", "unit_price": "0.78" } ], "limits": { "max_source_video_seconds": 30 } } ], "has_more": false } } ``` ## The product object | Field | Description | |---|---| | `id` | Product ID. | | `object` | Always `product`. | | `name` | Display name. | | `description` | Short description. | | `models` | The models of this product, passed as `model` to [`POST /v1/tasks`](https://nanoapi.ai/docs/tasks/create). | | `modes` | Accepted `mode` values. | | `output_type` | Result type. `video` for Genjutsu. | | `max_outputs` | The highest `num_outputs` you can request. | | `billing_unit` | Pricing unit. `second` for Genjutsu. | | `currency` | Always `USD`. | | `prices` | One entry per resolution. `unit_price` is the price per second in US dollars, as a [decimal string](https://nanoapi.ai/docs/conventions#amounts). How it's applied: [Genjutsu 1.0 → Pricing](https://nanoapi.ai/docs/genjutsu#pricing). | | `limits.max_source_video_seconds` | The longest source video, in seconds. Longer videos are rejected with `400 video_too_long`. See [Genjutsu 1.0 → Media](https://nanoapi.ai/docs/genjutsu#media). | ## Errors A failed request returns 4xx / 5xx (fields in [Response format](https://nanoapi.ai/docs/conventions#responses)). For example, an invalid key: ```json { "code": 401, "message": "The API key is invalid, deleted, or was reset.", "error": { "type": "invalid_api_key", "param": null, "details": null }, "request_id": "rq_01jb8z9x2c4v6b8n0m1q3w5e7r" } ``` | `error.type` | Status | When | What to do | |---|---|---|---| | `invalid_api_key` | 401 | The key is missing or malformed, doesn't exist, was deleted or has been reset. **The product list needs a key too.** | Use a valid key, see [Authentication](https://nanoapi.ai/docs/conventions#authentication). | | `account_suspended` | 403 | Your account has been suspended. | Contact support@nanoapi.ai. | | `rate_limited` | 429 | Too many reads: all read endpoints together (retrieving and listing tasks, the balance, products) allow 60 requests per 10 seconds per account, shared by all your keys (the website playground counts too). | Retry after `Retry-After` seconds. Prices rarely change, so there's no need to read them before every task. | | `internal_error` | 500 | Something went wrong on our side. | Reads have no side effects, so you can simply retry. |