{
  "openapi": "3.1.0",
  "info": {
    "title": "Ivan API",
    "version": "1",
    "summary": "Make narrated, hand-drawn explainer videos from a prompt.",
    "description": "Ivan turns a question into a narrated, illustrated explainer video. Create a video with one call, follow it until it finishes, then fetch its files or share it by link. Every error is an RFC 9457 problem document with a stable `code`; see https://tryivan.com/docs/api#errors.",
    "contact": { "name": "Ivan support", "email": "support@tryivan.com", "url": "https://tryivan.com/docs/api" },
    "termsOfService": "https://tryivan.com/privacy"
  },
  "externalDocs": { "description": "Guide and error reference", "url": "https://tryivan.com/docs/api" },
  "servers": [{ "url": "https://api.tryivan.com/v1", "description": "The API host. It answers nothing but /v1, and only to a secret key." }],
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "Account", "description": "Who a key is, and what it may spend." },
    { "name": "Videos", "description": "Make, follow, list, cancel and delete videos." },
    { "name": "Files and sharing", "description": "A finished video's files, and its public link." }
  ],
  "paths": {
    "/me": {
      "get": {
        "tags": ["Account"],
        "operationId": "getMe",
        "summary": "The key and the account it acts for",
        "description": "Needs no scope. With `usage:read`, it also says what the account can spend and what this key has committed this month.",
        "responses": {
          "200": {
            "description": "The account.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/videos": {
      "post": {
        "tags": ["Videos"],
        "operationId": "createVideo",
        "summary": "Make a video",
        "description": "Prices, holds and starts a video in one call. Scope `videos:create`. Send an `Idempotency-Key`: repeating the same request with the same key returns the same video and never makes a second one, so a create that timed out or lost its connection is always safe to repeat. The video is answered at once, `queued`; follow it with `GET /videos/{id}?wait_seconds=25`.",
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateVideo" } } }
        },
        "responses": {
          "202": {
            "description": "The video was accepted and is queued. A repeat of an accepted request answers the same video, with `Idempotent-Replayed: true`.",
            "headers": {
              "request-id": { "$ref": "#/components/headers/RequestId" },
              "location": { "description": "The video's path on the API host, such as /v1/videos/vid_01J8….", "schema": { "type": "string" } },
              "idempotent-replayed": { "description": "`true` when this answers a request already accepted under the same Idempotency-Key.", "schema": { "type": "string", "enum": ["true"] } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Video" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "413": { "$ref": "#/components/responses/TooLarge" },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      },
      "get": {
        "tags": ["Videos"],
        "operationId": "listVideos",
        "summary": "List videos",
        "description": "The account's videos, newest first, including those made in the studio. Scope `videos:read`.",
        "parameters": [
          { "name": "limit", "in": "query", "description": "Videos per page, from 1 to 100.", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
          { "name": "starting_after", "in": "query", "description": "The id of the last video of the previous page.", "schema": { "type": "string", "pattern": "^vid_[A-Za-z0-9]{1,64}$" } }
        ],
        "responses": {
          "200": {
            "description": "A page of videos.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VideoList" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/videos/estimate": {
      "post": {
        "tags": ["Videos"],
        "operationId": "estimateVideo",
        "summary": "Price a video without making it",
        "description": "Takes the same body as a create and holds nothing. Scope `videos:create`. `can_start` says whether a create would be accepted now, and `refusal` says why not.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateVideo" } } }
        },
        "responses": {
          "200": {
            "description": "The estimate.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Estimate" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "413": { "$ref": "#/components/responses/TooLarge" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/videos/{id}": {
      "parameters": [{ "$ref": "#/components/parameters/VideoId" }],
      "get": {
        "tags": ["Videos"],
        "operationId": "getVideo",
        "summary": "Get a video, optionally waiting for it to change",
        "description": "Scope `videos:read`. With `wait_seconds`, an unfinished video is held until its `status`, `stage` or `playable` changes, its `progress` moves a tenth further, or the wait runs out, whichever is first. An account holds at most 24 waits at a time; past that, or while Ivan holds as many waits as it can, a wait is answered at once with `Retry-After: 5`: ask again after it. Poll with `wait_seconds=25` in a loop until `status` is `completed`, `failed` or `canceled`.",
        "parameters": [
          { "name": "wait_seconds", "in": "query", "description": "How long to wait for a change, from 0 to 25 seconds.", "schema": { "type": "integer", "minimum": 0, "maximum": 25, "default": 0 } }
        ],
        "responses": {
          "200": {
            "description": "The video.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Video" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      },
      "delete": {
        "tags": ["Videos"],
        "operationId": "deleteVideo",
        "summary": "Delete a video",
        "description": "Deletes the whole video, every version of it, and its public link. Scope `videos:create`. A video still being made is refused: cancel it first.",
        "responses": {
          "200": {
            "description": "The video is deleted.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeletedVideo" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/videos/{id}/cancel": {
      "parameters": [{ "$ref": "#/components/parameters/VideoId" }],
      "post": {
        "tags": ["Videos"],
        "operationId": "cancelVideo",
        "summary": "Cancel a video being made",
        "description": "Scope `videos:create`. A queued video stops at once and its hold is released; one already being made stops at its next safe point. Cancelling is free until the first scene can be watched; after that, the scenes already made are charged at the video's rate and the rest of the hold is released. The answer is the video as it stands; follow it until `status` is `canceled`.",
        "responses": {
          "202": {
            "description": "Canceling.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Video" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/videos/{id}/content": {
      "parameters": [{ "$ref": "#/components/parameters/VideoId" }],
      "get": {
        "tags": ["Files and sharing"],
        "operationId": "getVideoContent",
        "summary": "Download a finished video's files",
        "description": "Scope `videos:read`. Serves the version the video plays now: its newest finished cut, studio edits included. The film supports `Range`, `If-Range`, `HEAD` and `ETag`. Files stream from Ivan's own host. An account may download 6 films at a time, each for up to 30 minutes; past that, `rate_limited`. While Ivan sends as many films as it can, `unavailable`; both carry `Retry-After`.",
        "parameters": [
          {
            "name": "variant",
            "in": "query",
            "description": "`video` (MP4), `captions` (WebVTT), `transcript` (plain text: the title, then each chapter's heading and its sentences) or `poster` (PNG).",
            "schema": { "type": "string", "enum": ["video", "captions", "transcript", "poster"], "default": "video" }
          }
        ],
        "responses": {
          "200": {
            "description": "The file.",
            "headers": {
              "request-id": { "$ref": "#/components/headers/RequestId" },
              "content-disposition": { "description": "For the film: inline; filename=\"vid_….mp4\".", "schema": { "type": "string" } }
            },
            "content": {
              "video/mp4": { "schema": { "type": "string", "contentMediaType": "video/mp4" } },
              "text/vtt": { "schema": { "type": "string" } },
              "text/plain": { "schema": { "type": "string" } },
              "image/png": { "schema": { "type": "string", "contentMediaType": "image/png" } }
            }
          },
          "206": { "description": "Part of the film, for a `Range` request.", "content": { "video/mp4": { "schema": { "type": "string", "contentMediaType": "video/mp4" } } } },
          "304": { "description": "Not modified: the file still matches the `ETag` sent in `If-None-Match`." },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "410": { "$ref": "#/components/responses/Gone" },
          "416": { "description": "invalid_request: the `Range` starts past the end of the film. `Content-Range` gives its length, as `bytes */<length>`. Ask for a range within it; repeating the same one fails the same way.", "headers": { "content-range": { "schema": { "type": "string" } } }, "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/downloads/{token}/{file}": {
      "parameters": [
        { "name": "token", "in": "path", "required": true, "description": "The link's own permission, as `download_url` carried it.", "schema": { "type": "string" } },
        { "name": "file", "in": "path", "required": true, "description": "The film's file name, as `download_url` carried it: drawn from the video's topic, ending `.mp4`.", "schema": { "type": "string" } }
      ],
      "get": {
        "tags": ["Files and sharing"],
        "operationId": "downloadVideoByLink",
        "summary": "Download a finished film from its link",
        "description": "The address a completed video carries in `download_url`. No key is sent: the link is the permission, for an hour, and is followed only while the key it was made for stands. Follow a link as the video gave it; one cannot be built. The film supports `Range`, `If-Range`, `HEAD` and `ETag`. A key's links have their own allowance and their own 6 downloads at a time, apart from the key's.",
        "security": [],
        "responses": {
          "200": {
            "description": "The film.",
            "headers": {
              "request-id": { "$ref": "#/components/headers/RequestId" },
              "content-disposition": { "description": "inline, with the link's file name.", "schema": { "type": "string" } }
            },
            "content": { "video/mp4": { "schema": { "type": "string", "contentMediaType": "video/mp4" } } }
          },
          "206": { "description": "Part of the film, for a `Range` request.", "content": { "video/mp4": { "schema": { "type": "string", "contentMediaType": "video/mp4" } } } },
          "304": { "description": "Not modified: the film still matches the `ETag` sent in `If-None-Match`." },
          "404": { "description": "not_found: the link was changed, has expired, or was made for a key since revoked or an app since disconnected. Read the video again for a new one.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
          "409": { "$ref": "#/components/responses/Conflict" },
          "410": { "$ref": "#/components/responses/Gone" },
          "416": { "description": "invalid_request: the `Range` starts past the end of the film. `Content-Range` gives its length, as `bytes */<length>`.", "headers": { "content-range": { "schema": { "type": "string" } } }, "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/videos/{id}/share": {
      "parameters": [{ "$ref": "#/components/parameters/VideoId" }],
      "post": {
        "tags": ["Files and sharing"],
        "operationId": "shareVideo",
        "summary": "Share a video by link",
        "description": "Scope `videos:share`. Publishes the video exactly as the studio's share switch does. Sharing again returns the same link.",
        "responses": {
          "200": {
            "description": "The video is shared.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Share" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "410": { "$ref": "#/components/responses/Gone" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      },
      "delete": {
        "tags": ["Files and sharing"],
        "operationId": "unshareVideo",
        "summary": "Stop sharing a video",
        "description": "Scope `videos:share`. The page stops answering at once. Sharing again later makes a new link.",
        "responses": {
          "200": {
            "description": "The video is no longer shared.",
            "headers": { "request-id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Share" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "ivan_sk_live_…",
        "description": "A secret key from the API keys section of your account at https://tryivan.com/?account=keys. Send it as `Authorization: Bearer ivan_sk_live_…`, only to the API host, and only from a server."
      }
    },
    "headers": {
      "RequestId": { "description": "The id Ivan's logs carry for this request. Quote it when you contact support.", "schema": { "type": "string" } },
      "RetryAfter": { "description": "Seconds to wait before trying again.", "schema": { "type": "integer", "minimum": 0 } }
    },
    "parameters": {
      "VideoId": { "name": "id", "in": "path", "required": true, "description": "A video id, such as vid_01J8ABC….", "schema": { "type": "string", "pattern": "^vid_[A-Za-z0-9]{1,64}$" } },
      "IdempotencyKey": { "name": "Idempotency-Key", "in": "header", "required": true, "description": "8 to 100 visible ASCII characters, such as a UUID. Use a new key for each new video, and the same key to repeat a request.", "schema": { "type": "string", "minLength": 8, "maxLength": 100, "pattern": "^[!-~]{8,100}$" } }
    },
    "schemas": {
      "CreateVideo": {
        "type": "object",
        "additionalProperties": false,
        "required": ["prompt"],
        "description": "What to make. A field the API does not take is refused, never ignored. Length is always chosen by the writer, within what `max_credits`, the key's monthly cap and the account's credits allow; a board-look prompt that could run longer spoken than they pay for holds the full 15 minutes instead.",
        "properties": {
          "prompt": { "type": "string", "minLength": 1, "maxLength": 4000, "description": "What the video should explain, in your words. Surrounding spaces are trimmed." },
          "power": { "type": "string", "enum": ["eco", "max"], "default": "eco", "description": "The writer and drawings: eco 200 credits a minute, max 400. Case does not matter." },
          "look": { "type": "string", "enum": ["illustrated", "flat", "doodle", "board", "folio", "poster", "blackboard"], "default": "illustrated", "description": "The drawing style. folio, poster and blackboard are other names for illustrated, flat and board." },
          "language": { "type": "string", "default": "en", "description": "The narration language, as a code Ivan narrates in, such as en, en-IN, hi or es." },
          "aspect": { "type": "string", "enum": ["16:9", "9:16"], "default": "16:9" },
          "narrator": { "type": "string", "enum": ["male", "female"], "default": "male" },
          "motion": { "type": "boolean", "description": "`false` keeps the drawings still." },
          "max_credits": { "type": "integer", "minimum": 1, "maximum": 100000000, "description": "The most this one video may hold and be charged, in credits." },
          "metadata": {
            "type": "object",
            "maxProperties": 20,
            "propertyNames": { "pattern": "^[A-Za-z0-9_.-]{1,40}$" },
            "additionalProperties": { "type": "string", "maxLength": 500 },
            "description": "Your own labels, returned with the video. Up to 20 keys of up to 40 letters, digits, _ - or ., each a string of up to 500 characters."
          },
          "logo_url": { "type": "string", "format": "uri", "maxLength": 2048, "description": "A public http or https link to a logo, a PNG or JPG file of up to 2 MB, carried in a corner of every frame in place of Ivan's mark, at no charge. Only for a video a team pays for; any other key is refused with permission_denied. Read once, when the video is made: the video keeps its own copy. A link no logo can be read from refuses the create with invalid_request." },
          "logo_corner": { "type": "string", "enum": ["bottom_right", "bottom_left", "top_right", "top_left"], "default": "bottom_right", "description": "Where the logo sits. Needs `logo_url`. A top corner is refused for a language whose titles Ivan can't letter beside it." },
          "brand": {
            "type": "object",
            "additionalProperties": false,
            "required": ["colors"],
            "description": "A company's brand colours to dress this video in, at no charge: the page becomes a pale tint of the first colour, lines and words a deep ink of the brand, and the light along each drawing its accent. Every drawing is still made and checked on white, and the page stays pale so it reads. Only for a video a team pays for (any other key is refused with permission_denied), on the illustrated and flat looks (others are refused with invalid_request). Each video takes its own, so a video made for another company takes that company's colours. The video and its estimate say the colours it uses.",
            "properties": {
              "colors": { "type": "array", "minItems": 1, "maxItems": 3, "items": { "type": "string", "pattern": "^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$" }, "description": "One to three colours as #RRGGBB, the main brand colour first." },
              "page": { "type": "string", "default": "light", "description": "The page: light (a pale tint of the first colour), white, or a pale #RRGGBB of your own, such as a cream. A colour too strong for a page is made a pale tint of itself." },
              "ink": { "type": "string", "description": "What lines and words are drawn in, as #RRGGBB. Left out, it is made from the brand's colours. One that would not read on the page is darkened until it does." },
              "accent": { "type": "string", "description": "The light that runs along each drawing as it is explained, as #RRGGBB. Left out, it is the second colour (the first, of one), or another of the brand's colours where that one is too near the ink or too pale to light a drawing. One that still would not show on the page is darkened until it does, and one too near the ink is lightened so the light shows on the lines." }
            }
          }
        }
      },
      "Brand": {
        "type": "object",
        "required": ["page", "ink", "accent", "desk"],
        "description": "The colours a video is dressed in, each as #RRGGBB.",
        "properties": {
          "page": { "type": "string", "description": "The paper every drawing is laid on." },
          "ink": { "type": "string", "description": "What lines, words and the title are drawn in." },
          "accent": { "type": "string", "description": "The light that runs along a drawing." },
          "desk": { "type": "string", "description": "What the sheets lie on as the film moves between them." }
        }
      },
      "Video": {
        "type": "object",
        "required": ["object", "id", "status", "stage", "progress", "playable", "topic", "power", "look", "aspect", "language", "duration_seconds", "watch_url", "share_url", "download_url", "download_expires_at", "credits", "music", "web_check", "error", "channel", "metadata", "logo_corner", "brand", "created_at", "completed_at"],
        "properties": {
          "object": { "const": "video" },
          "id": { "type": "string", "description": "vid_ and the video's own id." },
          "status": { "type": "string", "enum": ["queued", "in_progress", "completed", "failed", "canceled"] },
          "stage": { "type": ["string", "null"], "enum": ["checking", "writing", "drawing", "voicing", "rendering", "finishing", "reviewing", null], "description": "What is happening now, while `in_progress`; it only moves forward. Otherwise null." },
          "progress": { "type": "number", "minimum": 0, "maximum": 1, "description": "How far along the video is, to two decimals: it moves steadily while the video is made and never goes back. A video Ivan restarts after an interruption returns to `queued` and starts again from the scenes it saved. 1 only when completed." },
          "playable": { "type": "boolean", "description": "Whether its first scenes can be watched already, at `watch_url`." },
          "topic": { "type": "string", "description": "A short label for the video, drawn from its prompt." },
          "power": { "type": "string", "enum": ["free", "eco", "pro", "max"], "description": "The step the video was made at. `free` and `pro` are read only: a key cannot ask for a free video, and Pro is no longer made." },
          "look": { "type": "string", "enum": ["illustrated", "flat", "doodle", "board"] },
          "aspect": { "type": "string", "enum": ["16:9", "9:16"] },
          "language": { "type": "string" },
          "duration_seconds": { "type": ["integer", "null"], "description": "The delivered length it was charged for, in seconds, once settled." },
          "watch_url": { "type": ["string", "null"], "format": "uri", "description": "Where the account's signed-in owner watches and edits it. Private to the account. Null for a video made with a team's own key, which no one signs in to: share it, or use its files." },
          "share_url": { "type": ["string", "null"], "format": "uri", "description": "Its public link while shared, else null." },
          "download_url": { "type": ["string", "null"], "format": "uri", "description": "Where one completed video is read (`GET /videos/{id}`), a link to the film (MP4) that needs no key and works until `download_expires_at`: for a chat app that fetches a file, or a tool that uploads one. Anyone holding it can open the film until then; revoking the key, or disconnecting or reconnecting the app, ends it at once. New on every read. Null before the video is completed, in a list and in the answer to a create or a cancel, and for an app connected by sign-in with public links switched off." },
          "download_expires_at": { "type": ["integer", "null"], "description": "When `download_url` stops working, in Unix seconds: an hour after the read. Null when there is no link." },
          "credits": {
            "type": "object",
            "required": ["held", "charged"],
            "properties": {
              "held": { "type": "integer", "description": "Credits held for work still running." },
              "charged": { "type": "integer", "description": "Credits charged for finished work." }
            }
          },
          "music": {
            "type": ["object", "null"],
            "description": "The music under the narration and its licence, or null for a video without music. Publish credit wherever the video is published; the video's file carries the same line in its metadata.",
            "required": ["title", "by", "licence", "licence_url", "source", "credit"],
            "properties": {
              "title": { "type": "string" },
              "by": { "type": "string" },
              "licence": { "type": "string", "description": "The licence the music is used under, such as CC BY 4.0." },
              "licence_url": { "type": ["string", "null"], "format": "uri" },
              "source": { "type": "string", "format": "uri", "description": "Where the music came from." },
              "credit": { "type": "string", "description": "The credit in one line, ready to publish." }
            }
          },
          "web_check": {
            "type": ["object", "null"],
            "description": "For a prompt about something recent, the quick check of the web Ivan made before writing: when, and the pages its facts came from. Null for every other video, and until the check has found facts.",
            "required": ["checked_at", "sources"],
            "properties": {
              "checked_at": { "type": "integer", "description": "Unix seconds." },
              "sources": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": ["title", "site", "url"],
                  "properties": {
                    "title": { "type": "string" },
                    "site": { "type": "string", "description": "The page's site, such as nobelprize.org." },
                    "url": { "type": "string", "format": "uri" }
                  }
                }
              }
            }
          },
          "error": { "oneOf": [{ "type": "null" }, { "$ref": "#/components/schemas/VideoError" }] },
          "channel": { "type": "string", "description": "How the video was asked for: api; mcp: and the agent's name, such as mcp:claude-code, for one made through the MCP server; or studio for one made on the site." },
          "metadata": { "type": "object", "additionalProperties": { "type": "string" } },
          "logo_corner": { "type": ["string", "null"], "enum": ["bottom_right", "bottom_left", "top_right", "top_left", null], "description": "The corner its logo sits in, or null for a video without one." },
          "brand": { "oneOf": [{ "type": "null" }, { "$ref": "#/components/schemas/Brand" }], "description": "The colours it is dressed in, or null for a video made without brand colours." },
          "created_at": { "type": "integer", "description": "Unix seconds." },
          "completed_at": { "type": ["integer", "null"], "description": "Unix seconds, once completed." }
        }
      },
      "VideoError": {
        "type": "object",
        "required": ["code", "message"],
        "description": "Why a video failed. Nothing is charged for a failed video.",
        "properties": {
          "code": { "type": "string", "enum": ["generation_failed", "at_capacity", "drawing_failed", "voice_failed", "render_failed", "interrupted", "canceled", "source_unreadable", "internal_error"] },
          "message": { "type": "string" }
        }
      },
      "VideoList": {
        "type": "object",
        "required": ["object", "data", "has_more"],
        "properties": {
          "object": { "const": "list" },
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Video" } },
          "has_more": { "type": "boolean" }
        }
      },
      "DeletedVideo": {
        "type": "object",
        "required": ["object", "id", "deleted"],
        "properties": {
          "object": { "const": "video" },
          "id": { "type": "string" },
          "deleted": { "const": true }
        }
      },
      "Estimate": {
        "type": "object",
        "required": ["object", "max_credits", "credits_per_minute", "longest_seconds", "available_credits", "key_remaining_credits", "can_start", "refusal"],
        "properties": {
          "object": { "const": "estimate" },
          "max_credits": { "type": "integer", "description": "What a create would hold now, and the most the video can be charged." },
          "credits_per_minute": { "type": "integer", "description": "The rate for the requested power." },
          "longest_seconds": { "type": "integer", "description": "The longest the writer would be told the video may run." },
          "available_credits": { "type": "integer", "description": "What the account can spend now." },
          "key_remaining_credits": { "type": "integer", "description": "What this key may still commit this month." },
          "can_start": { "type": "boolean" },
          "refusal": { "type": ["string", "null"], "enum": ["max_credits_too_low", "spend_cap_reached", "insufficient_credits", "too_many_in_flight", "too_many_waiting", null], "description": "Why a create would be refused now, as its error code." },
          "brand": {
            "description": "Only for a request with brand colours: the colours the video would be dressed in, each colour that was changed so the video reads (fixed), and what you should know (warnings).",
            "allOf": [
              { "$ref": "#/components/schemas/Brand" },
              {
                "type": "object",
                "required": ["fixed", "warnings"],
                "properties": {
                  "fixed": { "type": "array", "items": { "type": "string" } },
                  "warnings": { "type": "array", "items": { "type": "string" } }
                }
              }
            ]
          }
        }
      },
      "Account": {
        "type": "object",
        "required": ["object", "id", "key", "limits"],
        "properties": {
          "object": { "const": "account" },
          "id": { "type": "string" },
          "key": {
            "type": "object",
            "required": ["id", "name", "hint", "mode", "scopes", "monthly_cap", "max_in_flight"],
            "properties": {
              "id": { "type": "string" },
              "name": { "type": "string" },
              "hint": { "type": "string", "description": "The key's prefix and last four characters." },
              "mode": { "type": "string", "enum": ["live", "test"] },
              "scopes": { "type": "array", "items": { "$ref": "#/components/schemas/Scope" } },
              "monthly_cap": { "type": "integer", "description": "Credits the key may commit in a calendar month (UTC)." },
              "max_in_flight": { "type": "integer", "description": "The key's own limit on videos queued or being made at once; the account's limits apply first." },
              "app": { "type": "string", "description": "Only for an app connected by sign-in, on the MCP server: which app, such as claude." },
              "video_cap": { "type": "integer", "description": "Only for an app connected by sign-in: the most credits one video may hold." },
              "committed_this_month": { "type": "integer", "description": "With usage:read. Held work at its hold, settled work at its charge." },
              "remaining_this_month": { "type": "integer", "description": "With usage:read." },
              "in_flight": { "type": "integer", "description": "With usage:read." }
            }
          },
          "limits": {
            "type": "object",
            "description": "The account's own, shared by all its keys and the site.",
            "required": ["videos_at_once", "videos_in_progress"],
            "properties": {
              "videos_at_once": { "type": "integer", "description": "Videos made at once; more still start while Ivan has machines to spare." },
              "videos_in_progress": { "type": "integer", "description": "The most videos made and waiting together; past it, too_many_waiting." },
              "making": { "type": "integer", "description": "With usage:read." },
              "waiting": { "type": "integer", "description": "With usage:read." }
            }
          },
          "credits": {
            "type": "object",
            "description": "With usage:read. For a team member, the team's pool.",
            "required": ["available"],
            "properties": { "available": { "type": "integer" } }
          }
        }
      },
      "Share": {
        "type": "object",
        "required": ["object", "video_id", "shared", "url", "embed_url", "shared_at"],
        "properties": {
          "object": { "const": "share" },
          "video_id": { "type": "string" },
          "shared": { "type": "boolean" },
          "url": { "type": ["string", "null"], "format": "uri", "description": "The public page, https://tryivan.com/f/…, while shared." },
          "embed_url": { "type": ["string", "null"], "format": "uri", "description": "A player another site may frame, while shared." },
          "shared_at": { "type": ["integer", "null"], "description": "Unix seconds." }
        }
      },
      "Scope": { "type": "string", "enum": ["videos:read", "videos:create", "videos:share", "files:write", "usage:read"] },
      "Problem": {
        "type": "object",
        "required": ["type", "title", "status", "detail", "code", "request_id"],
        "description": "RFC 9457 problem details, served as application/problem+json.",
        "properties": {
          "type": { "type": "string", "format": "uri", "description": "https://tryivan.com/docs/api#error-{code}" },
          "title": { "type": "string" },
          "status": { "type": "integer" },
          "detail": { "type": "string", "description": "A sentence for people. Do not parse it." },
          "code": {
            "type": "string",
            "description": "A stable machine code. Branch on this.",
            "enum": [
              "invalid_request", "unauthenticated", "invalid_api_key", "insufficient_scope", "account_not_approved", "permission_denied",
              "insufficient_credits", "spend_cap_reached", "max_credits_too_low", "team_limit", "team_paused", "team_tier", "content_refused",
              "research_needed", "not_found", "method_not_allowed", "conflict", "not_cancelable", "video_in_progress", "edit_in_progress", "video_not_ready",
              "too_many_keys", "content_expired", "request_too_large", "unsupported_media_type", "idempotency_key_reused", "rate_limited",
              "too_many_in_flight", "too_many_waiting", "queue_full", "generation_paused", "at_capacity", "screen_unavailable", "timeout", "unavailable"
            ]
          },
          "request_id": { "type": "string" },
          "param": { "type": "string", "description": "The parameter at fault, for invalid_request." },
          "retry_after": { "type": "integer", "description": "Seconds to wait, as in the Retry-After header." },
          "credits_needed": { "type": "integer", "description": "For a refusal over money: what the video may hold." },
          "suggestions": { "type": "array", "items": { "type": "string" }, "maxItems": 3, "description": "For content_refused: up to three other ways to ask, each a prompt you may send as it stands. Absent when there are none." },
          "credits_available": { "type": "integer", "description": "For a refusal over money: what the account, or the key this month, can cover." }
        }
      }
    },
    "responses": {
      "BadRequest": { "description": "The request is not valid: invalid_request (with `param`) or team_tier.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "Unauthorized": { "description": "unauthenticated or invalid_api_key.", "headers": { "www-authenticate": { "schema": { "type": "string" } } }, "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "PaymentRequired": { "description": "insufficient_credits, spend_cap_reached, max_credits_too_low or team_limit.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "Forbidden": { "description": "insufficient_scope, account_not_approved, permission_denied or team_paused.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "NotFound": { "description": "not_found: no such video for this key's account.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "Conflict": { "description": "conflict, not_cancelable, video_in_progress, edit_in_progress or video_not_ready.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "Gone": { "description": "content_expired: the film or its script is no longer kept.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "TooLarge": { "description": "request_too_large: the body is over 64 KiB.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "Unprocessable": { "description": "idempotency_key_reused; content_refused, when Ivan won't make a video of this prompt (`suggestions` may offer other ways to ask); research_needed, when the prompt needs research first (send it again with the findings written into the prompt); or a request Ivan read but cannot act on.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "TooManyRequests": { "description": "rate_limited, too_many_in_flight, too_many_waiting or queue_full. Wait for Retry-After.", "headers": { "retry-after": { "$ref": "#/components/headers/RetryAfter" } }, "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } },
      "Unavailable": { "description": "unavailable, timeout, generation_paused, at_capacity or screen_unavailable. Wait for Retry-After, then repeat the request; a create repeats with the same Idempotency-Key.", "headers": { "retry-after": { "$ref": "#/components/headers/RetryAfter" } }, "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } }
    }
  }
}
