{
  "openapi": "3.0.0",
  "info": {
    "title": "Neurall API (v3)",
    "version": "1.0.0",
    "description": "The public v3 surface: /v3/generations for async generations that produce a content asset, /v3/voices for the voice catalog and your own cloned voices, plus file uploads, per-feature pricing, read-only templates, and signed webhooks. Authenticate every call with an API key in the Authorization header. This document is machine-readable at https://api.neurall.io/v3/openapi.json (import it into Postman, or generate a client)."
  },
  "servers": [
    {
      "url": "https://api.neurall.io"
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization"
      }
    },
    "schemas": {
      "Generation": {
        "type": "object",
        "description": "An async generation. `status` moves from `pending` through `running` to `done`, `error` or `timeout`; once done, `output` holds the asset as a signed URL named by the kind (image, video or audio) plus its `mime`.",
        "properties": {
          "id": {
            "type": "string"
          },
          "projectId": {
            "type": "string"
          },
          "feature": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "image",
              "video",
              "audio",
              "text"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "running",
              "done",
              "timeout",
              "error"
            ]
          },
          "title": {
            "type": "string"
          },
          "params": {
            "type": "object",
            "description": "The submitted args (echoed back)"
          },
          "output": {
            "type": "object",
            "description": "Set once status is done. The finished asset is served under the generation's kind, mirroring the args side: exactly one of image, video or audio is present.",
            "properties": {
              "image": {
                "type": "string",
                "description": "URL of the result (kind image)"
              },
              "video": {
                "type": "string",
                "description": "URL of the result (kind video)"
              },
              "audio": {
                "type": "string",
                "description": "URL of the result (kind audio)"
              },
              "mime": {
                "type": "string",
                "description": "Media type of the result"
              }
            }
          },
          "error": {
            "type": "string"
          },
          "balanceUsed": {
            "type": "number"
          },
          "requestDate": {
            "type": "string",
            "format": "date-time"
          },
          "responseDate": {
            "type": "string",
            "format": "date-time"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "description": "API-created generations only: when the generation and its assets are deleted, 24 hours after the request. Absent on generations made in the app, which stay in its library."
          }
        }
      },
      "Template": {
        "type": "object",
        "description": "A published ready-made style. Pick one of its previews, resolve the preview id to a prompt via GET /v3/templates/{previewId}, and submit that prompt as `args.prompt` in POST /v3/generations.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "feature": {
            "type": "string",
            "description": "Canonical feature key the template belongs to"
          },
          "previews": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "description": "Preview id: pass it to GET /v3/templates/{previewId} to get the prompt"
                },
                "gender": {
                  "type": "string",
                  "description": "Reference gender of the example ('any' for non-person features)"
                },
                "image": {
                  "type": "string",
                  "description": "Thumbnail image URL (presigned, expires after ~6 hours)"
                },
                "video": {
                  "type": "string",
                  "description": "Preview video URL, present for video features (presigned, expires after ~6 hours)"
                }
              }
            }
          }
        }
      },
      "Voice": {
        "type": "object",
        "description": "A voice this project can speak with. `owner: neurall` is a catalog voice, ready to use and identical for every caller. `owner: project` is one of the project's own clones, which carries the same status lifecycle as a Generation while it trains. A voice's own id is what goes in `args.voiceId`.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "owner": {
            "type": "string",
            "enum": [
              "neurall",
              "project"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "running",
              "done",
              "error"
            ]
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "Catalog voices only. Stable handle, safe to hardcode; accepted anywhere the uuid is."
          },
          "tags": {
            "type": "array",
            "description": "Catalog voices only. Lowercase descriptors: gender, age, accent, style, plus the category (narration, conversational, character, creature).",
            "items": {
              "type": "string"
            }
          },
          "locale": {
            "type": "string",
            "description": "Catalog voices only"
          },
          "favorite": {
            "type": "boolean",
            "description": "Catalog voices only, whether this project starred it"
          },
          "preview": {
            "type": "string",
            "description": "Catalog voices only, a presigned audition clip (expires after ~6 hours)"
          },
          "error": {
            "type": "string"
          },
          "requestDate": {
            "type": "string"
          },
          "responseDate": {
            "type": "string"
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Generations",
      "description": "Async generations that produce content assets (v3)"
    },
    {
      "name": "Files",
      "description": "Input uploads for v3 generations"
    },
    {
      "name": "Pricing",
      "description": "Per-feature pricing of your plan (v3)"
    },
    {
      "name": "Voices",
      "description": "The Neurall voice catalog and the project's own cloned voices (v3)"
    },
    {
      "name": "Templates",
      "description": "Ready-made styles per feature: previews plus the prompt behind each one (v3)"
    },
    {
      "name": "MCP",
      "description": "The same /v3 surface exposed as MCP tools for AI agents (v3)"
    },
    {
      "name": "Webhooks",
      "description": "Instead of polling, pass `webhookUrl` (https, publicly reachable) when creating a\ngeneration or voice clone, and the API POSTs the finished generation to your endpoint once it\nreaches a terminal status.\n\n## Events\n\n| Event                | Sent when                                    | Body                          |\n|----------------------|----------------------------------------------|-------------------------------|\n| `generation.done`    | Generation finished successfully             | Matches `GET /v3/generations/{id}` |\n| `generation.error`   | Generation failed (charge refunded)          | Matches `GET /v3/generations/{id}` |\n| `generation.timeout` | Generation timed out (charge refunded)       | Matches `GET /v3/generations/{id}` |\n| `voice.done`         | A cloned voice finished training            | Matches `GET /v3/voices/{id}`      |\n| `voice.error`        | Voice cloning failed or timed out (refunded) | Matches `GET /v3/voices/{id}`      |\n\nEach delivery is a JSON POST of `{ \"event\": \"...\", \"generation\": { ... } }` (or\n`\"voice\"`), so a webhook consumer and a poller share one parser.\n\n## Verifying signatures\n\nEvery delivery is signed with your project's webhook signing secret, revealed on the playground's **API Keys** page (https://app.neurall.io/settings/project/api-keys):\n\n- `X-Neurall-Timestamp`: unix seconds when the delivery was sent\n- `X-Neurall-Signature`: one or more space-delimited entries of\n  `sha256=HMAC-SHA256(secret, \"<timestamp>.<raw body>\")`\n\nVerify against the **raw request bytes** (re-serializing parsed JSON can reorder\nkeys and break the match), accept if **any** entry matches, and reject stale\ntimestamps (e.g. older than 5 minutes) to block replays:\n\n```js\nconst crypto = require('crypto')\n\nfunction verifyNeurallWebhook(req, rawBody, secret) {\n  const ts = req.headers['x-neurall-timestamp']\n  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false\n  const expected = crypto.createHmac('sha256', secret)\n    .update(`${ts}.${rawBody}`).digest()\n  return (req.headers['x-neurall-signature'] || '').split(' ').some((entry) => {\n    const candidate = Buffer.from(entry.replace('sha256=', ''), 'hex')\n    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)\n  })\n}\n```\n\n## Key rotation\n\nRotating the secret (also on the API Keys page) issues a new key immediately; for 24 hours deliveries carry **two** signature entries (new + old key), so deploy the new secret to your servers at your own pace, and verification keeps passing throughout. The accept-if-any loop above handles this automatically.\n\n## Delivery semantics\n\n- Sent **once per generation** on the first terminal state; duplicate sends are\n  prevented server-side.\n- Respond with any **2xx** within 5 seconds. Non-2xx or timeout is retried once\n  after 2 seconds; after that the delivery is dropped, because webhooks are\n  best-effort, **polling remains the source of truth**.\n- The last delivery outcome is visible to support for debugging; your endpoint\n  should be idempotent regardless (the `generation.id` / `voice.id` is the\n  dedupe key).\n- API-created generation outputs expire (`expiresAt`), so download the asset when\n  the webhook arrives rather than hotlinking.\n"
    }
  ],
  "paths": {
    "/v3/generations": {
      "post": {
        "summary": "Create Generation",
        "description": "Submits an async generation and returns immediately with status `pending`. Poll GET /v3/generations/{id} until the status is terminal, then read `output`, or pass `webhookUrl` to be notified instead (polling still works as a fallback).\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Generations"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "feature": {
                    "type": "string",
                    "description": "The generation feature to run, namespaced by output kind ('<kind>/<name>'). The pre-namespacing flat spellings (e.g. image-generation, logo-reveal) are still accepted.\n",
                    "enum": [
                      "image/self-portrait",
                      "image/generation",
                      "image/product-shot",
                      "image/youtube-thumbnail",
                      "image/ad",
                      "video/logo-reveal",
                      "video/object-reveal",
                      "video/avatar",
                      "audio/voiceover"
                    ]
                  },
                  "args": {
                    "type": "object",
                    "description": "Feature-specific inputs. Reference images and audio are file ids from /v3/files/upload. Every input is validated before the generation is accepted or billed; a file that fails answers a descriptive 400 and nothing is charged. Each feature's own docs page lists its arguments, formats and size caps."
                  },
                  "webhookUrl": {
                    "type": "string",
                    "description": "Optional https URL to POST the finished generation to when it reaches a terminal status (done / error / timeout). Deliveries are always signed with your project's webhook secret, revealed on the playground's API Keys page. Delivery is best-effort with a brief retry; polling remains authoritative.\n"
                  }
                },
                "required": [
                  "feature",
                  "args"
                ]
              }
            }
          }
        },
        "callbacks": {
          "generationFinished": {
            "{$request.body#/webhookUrl}": {
              "post": {
                "summary": "Generation finished",
                "description": "Sent once when the generation reaches a terminal status. The body matches GET /v3/generations/{id}, plus an `event` discriminator (`generation.done` | `generation.error` | `generation.timeout`). Headers: X-Neurall-Timestamp (unix seconds) and X-Neurall-Signature, one or more space-delimited entries of sha256=HMAC-SHA256(secret, \"<timestamp>.<raw body>\"), signed with your project's webhook secret (two entries during the 24h window after a rotation). Verify against the RAW body and accept if any entry matches; reject stale timestamps to prevent replays.\n",
                "requestBody": {
                  "content": {
                    "application/json": {
                      "schema": {
                        "type": "object",
                        "properties": {
                          "event": {
                            "type": "string",
                            "enum": [
                              "generation.done",
                              "generation.error",
                              "generation.timeout"
                            ]
                          },
                          "generation": {
                            "$ref": "#/components/schemas/Generation"
                          }
                        }
                      }
                    }
                  }
                },
                "responses": {
                  "200": {
                    "description": "Acknowledged (any 2xx stops retries)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The pending generation",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "generation": {
                      "$ref": "#/components/schemas/Generation"
                    }
                  }
                },
                "example": {
                  "generation": {
                    "id": "9f2c1d84-6a3b-4f21-9c77-2f0a1b8e5d43",
                    "projectId": "3e8b2f60-91c4-4d7a-8f02-6b1de0a97c55",
                    "feature": "image/self-portrait",
                    "kind": "image",
                    "status": "pending",
                    "params": {
                      "image": "f_9c21e0b4",
                      "prompt": "Cinematic studio portrait, moody side light against a deep grey backdrop.",
                      "aspectRatio": "3:4"
                    },
                    "balanceUsed": 0.05,
                    "requestDate": "2026-08-25T17:04:12.000Z",
                    "expiresAt": "2026-08-26T17:04:12.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Bad request (unknown feature, invalid args)"
          },
          "401": {
            "description": "Unauthorized"
          },
          "402": {
            "description": "Insufficient balance"
          }
        }
      },
      "get": {
        "summary": "List Generations",
        "description": "The project's generations, newest first, keyset-paginated via `cursor`. Use it to recover ids you lost (a process that crashed between submitting and recording the id can find its work here) and to show recent activity in your own dashboard.\n\nIt is NOT an archive: a generation created through the API drops out of this list once its `expiresAt` passes, 24 hours after the request, so treat it as a window on the last day rather than as storage.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Generations"
        ],
        "parameters": [
          {
            "name": "feature",
            "in": "query",
            "required": false,
            "description": "Only generations for one feature, e.g. `video/logo-reveal`. The legacy flat spelling is accepted too and matches the same rows.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Generations per page. Defaults to 24, and anything above 100 is clamped to 100 rather than rejected.",
            "schema": {
              "type": "integer",
              "default": 24,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "The `nextCursor` of the previous page",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of generations",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "generations": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Generation"
                      }
                    },
                    "nextCursor": {
                      "type": "string",
                      "nullable": true,
                      "description": "Pass it back as `?cursor=` for the next page; null on the last one."
                    }
                  }
                },
                "example": {
                  "generations": [
                    {
                      "id": "9f2c1d84-6a3b-4f21-9c77-2f0a1b8e5d43",
                      "feature": "image/self-portrait",
                      "kind": "image",
                      "status": "done",
                      "output": {
                        "image": "https://cdn.neurall.io/…/image.png",
                        "mime": "image/png"
                      },
                      "balanceUsed": 0.05,
                      "requestDate": "2026-08-25T17:04:12.000Z",
                      "responseDate": "2026-08-25T17:04:41.000Z",
                      "expiresAt": "2026-08-26T17:04:12.000Z"
                    }
                  ],
                  "nextCursor": null
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      }
    },
    "/v3/generations/{id}": {
      "get": {
        "summary": "Get Generation",
        "description": "The one call you make after submitting: poll it every few seconds (5 to 10 for the minute-scale renders: video, voice cloning) until `status` leaves `pending` and `running`, then read `output`. Statuses are `pending`, `running`, `done`, `error` and `timeout`; loop while the status is one of the first two rather than waiting for `done`, or a failed generation spins forever.\n\nThe asset in `output` (`image`, `video` or `audio`, named by the generation's kind) is a signed URL you should download, NOT a permanent address: a generation created through the API is reaped at `expiresAt`, 24 hours after the request, and this call then answers 404 as though the generation never existed.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Generations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The generation id returned by POST /v3/generations.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The generation",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "generation": {
                      "$ref": "#/components/schemas/Generation"
                    }
                  }
                },
                "example": {
                  "generation": {
                    "id": "9f2c1d84-6a3b-4f21-9c77-2f0a1b8e5d43",
                    "projectId": "3e8b2f60-91c4-4d7a-8f02-6b1de0a97c55",
                    "feature": "image/self-portrait",
                    "kind": "image",
                    "status": "done",
                    "params": {
                      "image": "f_9c21e0b4",
                      "prompt": "Cinematic studio portrait, moody side light against a deep grey backdrop.",
                      "aspectRatio": "3:4"
                    },
                    "output": {
                      "image": "https://cdn.neurall.io/…/image.png",
                      "mime": "image/png"
                    },
                    "balanceUsed": 0.05,
                    "requestDate": "2026-08-25T17:04:12.000Z",
                    "responseDate": "2026-08-25T17:04:41.000Z",
                    "expiresAt": "2026-08-26T17:04:12.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Generation not found"
          }
        }
      },
      "delete": {
        "summary": "Delete Generation",
        "description": "Removes the generation and its rendered assets now, instead of waiting for the 24 hour expiry to do it. Reach for this when something has to be gone on request rather than on schedule: a user withdrew consent, or a render came back wrong and you do not want it reachable while your retry runs.\n\nIt does not refund anything, since the render already happened, and it cannot cancel work that is still in flight.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Generations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The generation id returned by POST /v3/generations.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    }
                  }
                },
                "example": {
                  "success": true
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Generation not found"
          }
        }
      }
    },
    "/v3/files/upload": {
      "post": {
        "summary": "Upload Files",
        "description": "Turns a local file into a file id: `args` fields like `image` and `audio` take these ids, never a URL or base64. Call this first, then generate.\n\nSend `multipart/form-data` with a `files` field: up to 10 files, 30 MB each; images, audio, video or documents (PDF, ZIP, text, JSON). Uploading is permissive on purpose; each feature checks its own limits at generation time, so see that feature's docs for what it accepts.\n\nAn upload is deleted 24 hours after it lands. Keep the original on your side and upload it again when you need it later.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Files"
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "files": {
                    "type": "array",
                    "description": "One or more files, all under the same `files` field name. Up to 10 per request, 30 MB each; over either limit answers 400.",
                    "items": {
                      "type": "string",
                      "format": "binary"
                    }
                  }
                },
                "required": [
                  "files"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The uploaded file ids",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "files": {
                      "type": "array",
                      "description": "One entry per uploaded file, in submission order.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "The file id to pass in a feature's args."
                          },
                          "originalName": {
                            "type": "string"
                          },
                          "size": {
                            "type": "integer",
                            "description": "Bytes received."
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "files": [
                    {
                      "id": "f_9c21e0b4",
                      "originalName": "portrait.jpg",
                      "size": 734112
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      }
    },
    "/v3/files/upload-url": {
      "post": {
        "summary": "Create an Upload URL",
        "description": "Both routes produce the same file id; this one is for callers the multipart route shuts out. The id comes first and the bytes go second, straight to storage, over a signed URL that carries its own credential for 15 minutes. No Authorization header travels with the bytes, so an agent on MCP (whose key lives in its client config, not its shell) or a client you would rather not hand your key can finish the upload. If you can POST the file, use /v3/files/upload instead.\n\nThe multipart route's limits apply (30 MB, same accepted types). Both echoed headers are signed: a PUT that changes or omits either is refused by storage with a 403.\n\nThe id means nothing until the PUT answers 200, then behaves exactly like a multipart id, 24 hour expiry included.\n",
        "x-steps": [
          {
            "title": "Ask for the URL",
            "body": "POST the file's exact size in bytes and its media type. Nothing is uploaded yet; you are reserving an id and getting a signed address for the bytes.",
            "curl": "curl -X POST \"https://api.neurall.io/v3/files/upload-url\" \\\n  -H \"Authorization: nl-YOUR_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"bytes\": 734112, \"contentType\": \"image/jpeg\" }'",
            "response": {
              "file": {
                "id": "f_51ac9d20"
              },
              "uploadUrl": "https://s3.amazonaws.com/neurall-uploads/f_51ac9d20?X-Amz-Signature=…",
              "method": "PUT",
              "headers": {
                "Content-Type": "image/jpeg",
                "Content-Length": "734112"
              },
              "expiresIn": 900
            }
          },
          {
            "title": "PUT the bytes to uploadUrl",
            "body": "Send the file to `uploadUrl` with exactly the echoed headers and no Authorization header (curl derives Content-Length from the file itself). A 200 with an empty body means the id is live. The URL is good for 15 minutes; past that, start over at step 1.",
            "curl": "curl -X PUT \"https://s3.amazonaws.com/neurall-uploads/f_51ac9d20?X-Amz-Signature=…\" \\\n  -H \"Content-Type: image/jpeg\" \\\n  --data-binary @portrait.jpg"
          },
          {
            "title": "Use the id like any upload",
            "body": "The id now behaves exactly like one from POST /v3/files/upload: pass it in a feature's args, and it expires 24 hours after it landed.",
            "curl": "curl -X POST \"https://api.neurall.io/v3/generations\" \\\n  -H \"Authorization: nl-YOUR_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"feature\": \"image/self-portrait\",\n    \"args\": {\n      \"image\": \"f_51ac9d20\",\n      \"prompt\": \"Cinematic studio portrait, moody side light.\"\n    }\n  }'",
            "response": {
              "generation": {
                "id": "9f2c1d84-6a3b-4f21-9c77-2f0a1b8e5d43",
                "feature": "image/self-portrait",
                "kind": "image",
                "status": "pending"
              }
            }
          }
        ],
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Files"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "bytes": {
                    "type": "integer",
                    "description": "The file's exact size in bytes. Signed into the URL, so the upload must send precisely this many. Over 30 MB answers 413."
                  },
                  "contentType": {
                    "type": "string",
                    "description": "The file's media type, e.g. `image/jpeg`. Also signed, and stored on the object: promoting an upload into a saved subject or product copies it onto the permanent asset. Must be an accepted type (image, audio, video, or PDF/ZIP/text/JSON documents) or the request answers 415."
                  }
                },
                "required": [
                  "bytes",
                  "contentType"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The file id and where to PUT the bytes",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "file": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string"
                        }
                      }
                    },
                    "uploadUrl": {
                      "type": "string"
                    },
                    "method": {
                      "type": "string"
                    },
                    "headers": {
                      "type": "object",
                      "description": "The exact headers the PUT must carry."
                    },
                    "expiresIn": {
                      "type": "integer",
                      "description": "Seconds the URL stays valid."
                    }
                  }
                },
                "example": {
                  "file": {
                    "id": "f_51ac9d20"
                  },
                  "uploadUrl": "https://s3.amazonaws.com/neurall-uploads/f_51ac9d20?X-Amz-Signature=…",
                  "method": "PUT",
                  "headers": {
                    "Content-Type": "image/jpeg",
                    "Content-Length": "734112"
                  },
                  "expiresIn": 900
                }
              }
            }
          },
          "400": {
            "description": "Missing or malformed bytes or contentType"
          },
          "401": {
            "description": "Unauthorized"
          },
          "413": {
            "description": "The file is over 30 MB"
          }
        }
      }
    },
    "/v3/pricing": {
      "get": {
        "summary": "Get Pricing",
        "description": "What every feature costs YOU, on your plan, so a call can be costed before it is made. Fetch it to show a price in your own UI, to budget a batch, or to decide whether an option is worth its surcharge, rather than hardcoding numbers that go stale when your plan changes.\n\nPrices are not one flat figure across the platform: `price` is per call for most features and per unit for the ones billed by output length (a reveal is charged per second of finished clip), and `variants` holds the multipliers that options like resolution apply on top.\n\nNothing here is a quote or a hold; the balance is charged when a generation is accepted.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Pricing"
        ],
        "responses": {
          "200": {
            "description": "Map of feature name to price info",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "prices": {
                      "type": "object",
                      "description": "One entry per feature your plan prices, keyed by the feature name you send as `feature` when generating (image/self-portrait, video/object-reveal, ...). A feature you cannot call does not appear.",
                      "x-additionalPropertiesName": "feature",
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "price": {
                            "type": "number",
                            "description": "The base price in USD. Per call for flat features; per unit for the ones billed by output length (a reveal per second of finished clip, an avatar video per started 15 seconds, a voiceover per started 1,000 characters of script)."
                          },
                          "variants": {
                            "type": "object",
                            "description": "Present only when one of the feature's arguments changes the price. Keyed by that argument's name; each entry maps the values you can send to the multiplier applied on top of `price`. You never send a variant key: the server reads the argument off your call and applies the multiplier itself, so the cost of a call is `price` times the multiplier of each option it uses. A boolean argument maps as `off`/`on`. `\"fineDetails\": {\"off\": 1, \"on\": 4}` reads as: a fine-details second costs 4x the base rate, and `\"resolution\": {\"720p\": 1, \"1080p\": 1.5}` prices a 1080p avatar block at 1.5x the 720p one. Absent entirely, the feature has one flat price."
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "prices": {
                    "image/self-portrait": {
                      "price": 0.05
                    },
                    "image/product-shot": {
                      "price": 0.15,
                      "variants": {
                        "fineDetails": {
                          "off": 1,
                          "on": 3.5
                        }
                      }
                    },
                    "video/object-reveal": {
                      "price": 0.25,
                      "variants": {
                        "fineDetails": {
                          "off": 1,
                          "on": 4
                        }
                      }
                    },
                    "video/avatar": {
                      "price": 0.75,
                      "variants": {
                        "resolution": {
                          "720p": 1,
                          "1080p": 1.5
                        }
                      }
                    },
                    "audio/voice-clone": {
                      "price": 3
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      }
    },
    "/v3/voices": {
      "post": {
        "summary": "Clone a Voice",
        "description": "Clone a voice from an audio sample: upload about thirty seconds of clean speech to /v3/files/upload, then POST its file id here. Returns immediately with `status: running`; poll GET /v3/voices/{id} until it is `done`, or pass `webhookUrl` and be told.\n\nThe id you get back IS the voiceId, usable in `args.voiceId` on audio/voiceover and video/avatar. Charged on submit like a generation, refunded on error or timeout. An organization may have 5 voices training at once.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Voices"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Name for the voice."
                  },
                  "audio": {
                    "type": "string",
                    "description": "File id of the audio sample, from POST /v3/files/upload. MP3, WAV, M4A, OGG, FLAC or WebM, holding a real audio stream."
                  },
                  "description": {
                    "type": "string"
                  },
                  "webhookUrl": {
                    "type": "string",
                    "description": "Optional https URL to POST the finished voice to (`voice.done` / `voice.error`), signed exactly like a generation webhook.\n"
                  }
                },
                "required": [
                  "name",
                  "audio"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The voice, still training",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "voice": {
                      "$ref": "#/components/schemas/Voice"
                    }
                  }
                },
                "example": {
                  "voice": {
                    "id": "d77c4b2e-5a90-4f13-b8c6-1e2f3a4b5c6d",
                    "owner": "project",
                    "status": "running",
                    "name": "Founder voice",
                    "requestDate": "2026-08-25T17:04:12.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing name or audio, or the sample is too short"
          },
          "401": {
            "description": "Unauthorized"
          },
          "402": {
            "description": "Insufficient balance"
          },
          "429": {
            "description": "Too many voices training at once (Retry-After header)"
          }
        }
      },
      "get": {
        "summary": "List Voices",
        "description": "Every voice this project can speak with: its own cloned voices first (`owner: project`), then the Neurall catalog (`owner: neurall`), which is ready-made and identical for every caller. Filter with `?owner=`.\n\nPass a voice's `id` (or a catalog `slug`, the stable handle worth hardcoding) as `args.voiceId` in an audio/voiceover or video/avatar generation. `preview` is a presigned audition clip on catalog voices and expires after ~6 hours; a clone carries a `status` instead, until it is `done`.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Voices"
        ],
        "parameters": [
          {
            "name": "owner",
            "in": "query",
            "required": false,
            "description": "`project` for this project's own clones, `neurall` for the ready-made catalog. Omit for both. Anything else is a 400. `tag` and `locale` describe catalog rows only, so they are ignored on clones rather than silently hiding them.\n",
            "schema": {
              "type": "string",
              "enum": [
                "neurall",
                "project"
              ]
            }
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "description": "Only voices carrying ALL of these tags. May repeat (?tag=female&tag=warm). Tags are lowercase descriptors: gender, age, accent, style, plus the category (narration, conversational, character, creature).\n",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "locale",
            "in": "query",
            "required": false,
            "description": "Only voices recorded for one locale, matched exactly against the voice's own `locale` (e.g. `en-US`). It is not a prefix: `en` returns nothing.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "favorites",
            "in": "query",
            "required": false,
            "description": "Only the voices this project has starred. Accepts 1, true or yes.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "This project's clones, then the published catalog",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "voices": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Voice"
                      }
                    }
                  }
                },
                "example": {
                  "voices": [
                    {
                      "id": "d77c4b2e-5a90-4f13-b8c6-1e2f3a4b5c6d",
                      "owner": "project",
                      "status": "done",
                      "name": "Founder voice"
                    },
                    {
                      "id": "5b3a9d10-2c47-4e8f-a6b1-9d0c3e7f2a84",
                      "owner": "neurall",
                      "slug": "aria",
                      "name": "Aria",
                      "tags": [
                        "female",
                        "warm",
                        "narration"
                      ],
                      "locale": "en-US",
                      "favorite": false,
                      "preview": "https://cdn.neurall.io/…/aria-preview.mp3"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      }
    },
    "/v3/voices/{id}": {
      "get": {
        "summary": "Get Voice",
        "description": "Resolves ONE voice without pulling the whole list: a catalog voice by uuid or by slug, or one of this project's own clones by its id. The shapes cannot collide, so a caller holding a voiceId never has to know which kind it is holding.\n\nOn a clone this is the poll: after POST /v3/voices it answers `status: running` until the voice is `done` (or `error`), which is the same loop a generation uses.\n\nOn a catalog voice there are two reasons to reach for it. First, `preview` is a presigned audition clip that expires after about 6 hours, so this is how you refresh the sample for a voice your app already knows about and lets a user play. Second, it is the cheap way to confirm a slug you hardcoded is still published: a retired voice answers 404 here rather than failing later, inside a generation you have already paid for.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Voices"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "A catalog voice's uuid or slug, or one of this project's clone ids.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The voice",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "voice": {
                      "$ref": "#/components/schemas/Voice"
                    }
                  }
                },
                "example": {
                  "voice": {
                    "id": "d77c4b2e-5a90-4f13-b8c6-1e2f3a4b5c6d",
                    "owner": "project",
                    "status": "done",
                    "name": "Founder voice",
                    "requestDate": "2026-08-25T17:04:12.000Z",
                    "responseDate": "2026-08-25T17:09:35.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Voice not found"
          }
        }
      },
      "delete": {
        "summary": "Delete a Voice",
        "description": "Delete one of this project's cloned voices. It stops resolving everywhere, so anything still passing that id as `args.voiceId` starts failing, and there is no undelete: cloning again is a new generation. Catalog voices are not deletable.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Voices"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    }
                  }
                },
                "example": {
                  "success": true
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Not one of this project's cloned voices"
          }
        }
      }
    },
    "/v3/voices/{id}/favorite": {
      "put": {
        "summary": "Favorite Voice",
        "description": "Star a catalog voice for the calling PROJECT (not the user, so a team shares one shortlist). Idempotent: starring twice is one row. The catalog row itself is unchanged for everyone else; `favorite` is computed per request.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Voices"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The voice's uuid or its slug.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The favorite state after the call",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "favorite": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Voice not found"
          }
        }
      },
      "delete": {
        "summary": "Unfavorite Voice",
        "description": "Unstar a catalog voice for the calling project. Idempotent.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Voices"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The voice's uuid or its slug.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The favorite state after the call",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "favorite": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Voice not found"
          }
        }
      }
    },
    "/v3/mcp": {
      "post": {
        "summary": "MCP endpoint",
        "description": "The public /v3 generation features exposed as MCP (Model Context Protocol) tools, so an agent can browse templates, check pricing, upload inputs and generate on your behalf. Streamable HTTP transport, stateless: every call is a POST and there is no session to resume, so GET and DELETE return 405. Authenticate exactly as elsewhere, with the bare API key in the Authorization header and no `Bearer` prefix.\n\nTool calls loop back through these same REST routes, so billing and concurrency limits are identical to calling the API directly. Generation tools submit and return the generation id; the agent polls `get_generation`, matching the async contract of POST /v3/generations. Each feature has its own typed tool (generate_ad, generate_youtube_thumbnail, generate_self_portrait, generate_product_shot, generate_avatar_video, generate_logo_reveal, generate_object_reveal, generate_voiceover); create_generation reaches the same features in packed form.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "MCP"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "A JSON-RPC 2.0 message, e.g. `tools/list` or `tools/call`."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A JSON-RPC response. The transport answers as an SSE stream (`text/event-stream`), so send `Accept: application/json, text/event-stream`.\n"
          },
          "401": {
            "description": "Unauthorized"
          },
          "405": {
            "description": "Method not allowed (the endpoint is stateless and POST only)"
          }
        }
      }
    },
    "/v3/templates": {
      "get": {
        "summary": "List Templates",
        "description": "Published ready-made templates for a feature. Each template carries one or more previews: a thumbnail image, plus a preview video when the feature produces video (e.g. video/logo-reveal). Preview URLs are presigned and expire after ~6 hours. To use a template, resolve its preview id via GET /v3/templates/{previewId} and submit the returned prompt as `args.prompt` in POST /v3/generations.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Templates"
        ],
        "parameters": [
          {
            "name": "feature",
            "in": "query",
            "required": false,
            "description": "Feature key to list templates for (default image/self-portrait). Canonical '<kind>/<name>' spellings and the legacy flat spellings are both accepted.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Only templates in this category. Which categories exist depends on the feature, e.g. video/logo-reveal splits into 'motion' (intro) and 'outro'.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "title",
            "in": "query",
            "required": false,
            "description": "Only templates with these exact titles. May REPEAT (?title=Ember+Rush&title=Smoke+Part) to pick a named set without listing a whole library.\n",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "perCategory",
            "in": "query",
            "required": false,
            "description": "At most N templates of EACH category, rather than the first N rows overall. Rows come back in creation order, so a plain limit on a mixed library returns N of whatever was seeded first and never reaches the other categories. Capped at 50.\n",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Cap the number of templates returned. Useful when a few examples are enough: a full library can be hundreds of rows, each carrying presigned preview URLs. Capped at 500.\n",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The feature's published templates",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "templates": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Template"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      }
    },
    "/v3/templates/{previewId}": {
      "get": {
        "summary": "Get Template Prompt",
        "description": "Resolves a preview id from the templates listing to its generation prompt, the exact string to send as `args.prompt` in POST /v3/generations.\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "tags": [
          "Templates"
        ],
        "parameters": [
          {
            "name": "previewId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The preview's prompt and its template context",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "preview": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "templateId": {
                          "type": "string"
                        },
                        "title": {
                          "type": "string"
                        },
                        "feature": {
                          "type": "string"
                        },
                        "gender": {
                          "type": "string"
                        },
                        "prompt": {
                          "type": "string",
                          "description": "Ready to send as `args.prompt` in POST /v3/generations"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Preview not found"
          }
        }
      }
    }
  }
}