Automated Video Generation with the InkSlop API

How to generate, render and publish videos from code with the InkSlop API, from your first request to batches built from data, with working Python examples and the limits to plan around.

Andy Woods
Illustration of script pages on a conveyor belt turning into film reels

Everything you can make in InkSlop's editor, you can also make from code. The API takes a script, renders it on our servers, and can publish the result straight to YouTube or TikTok. That makes it possible to run a channel from a spreadsheet, generate a video for every new product in a shop, or add video to your own app, without anyone opening an editor.

This post walks through how it works, with working Python examples, and is honest about the limits you need to plan around. The full field-by-field reference is in the API docs.

Contents

How It Works

A video is described by a script in InkSlop's tag language: a background, optional music, and narration, with effects and images placed at the words they belong to. You send the script, get back a task ID, and either poll until the video is ready or let InkSlop call your server with a webhook.

sequenceDiagram
    participant You as Your code
    participant API as InkSlop API
    participant R as Render workers
    participant YT as YouTube / TikTok
    You->>API: POST /videos (script, publish targets)
    API-->>You: task_id
    API->>R: narration, then render
    alt polling
        loop every 5-10 seconds
            You->>API: GET /videos/{task_id}
            API-->>You: status, progress
        end
    end
    R-->>API: finished video
    API->>You: webhook video.completed (if you added an endpoint)
    API->>YT: upload (if publish targets were given)

Because the script is plain text, anything that can build a string can make videos: a Python loop, a spreadsheet export, an LLM, a CMS webhook.

Get an API Key

Create a key in the dashboard under Accounts. Keys start with isk_ and are shown once, so store it somewhere safe, such as an environment variable, not in your code. Every request sends it as a bearer token:

Authorization: Bearer isk_your_key_here

Your First Video

import os, time, requests

BASE = "https://inkslop.com/api/proxy/api/v1"
H = {"Authorization": f"Bearer {os.environ['INKSLOP_API_KEY']}"}

SCRIPT = """
[video file="video/minecraft/BXUA2FncVPI.mp4" size="content" fit="loop"]
  [music file="music/Carefree.opus" volume="0.25" fit="loop"]
    [tts engine="kokoro_tts" voice="af_heart" subtitles="true"]
      Octopuses have three hearts and blue blood.
      The heart that pumps blood to the body stops beating while they swim, one reason they prefer to crawl.
    [/tts]
  [/music]
[/video]
"""

r = requests.post(f"{BASE}/videos", headers=H, json={
    "script": SCRIPT,
    "video_name": "octopus-hearts",
    "resolution": "9:16",
    "estimated_duration": 12,
})
r.raise_for_status()
task_id = r.json()["task_id"]

while True:
    status = requests.get(f"{BASE}/videos/{task_id}", headers=H).json()
    if status["status"] in ("done", "error"):
        break
    time.sleep(8)

print(status["status"], status.get("download_url"), status.get("credits_used"))

A few things this shows:

  • Layers nest. The background video wraps the music, which wraps the narration. The narration decides how long the video is, and size="content" fit="loop" makes the background and music loop to match.
  • Files are found automatically. Any file from InkSlop's library (video/..., music/...) can be referenced by its path, and so can your own uploads.
  • estimated_duration is only used for the credit check before rendering. The actual charge is based on the finished video.

Every tag and parameter is listed in the DSL reference, and the formats and scripting guide explains how scripts are structured.

Building Scripts From Data

The real power is generating scripts from data. Keep the template in one function and feed it rows:

def clean(text: str) -> str:
    # Square brackets start a tag, so strip them from narration text
    return text.replace("[", "(").replace("]", ")").strip()

def fact_video(fact: str, voice: str = "af_heart") -> str:
    return f"""
[video file="video/minecraft/BXUA2FncVPI.mp4" size="content" fit="loop"]
  [music file="music/Carefree.opus" volume="0.25" fit="loop"]
    [tts engine="kokoro_tts" voice="{voice}" subtitles="true"]
      {clean(fact)}
    [/tts]
  [/music]
[/video]
"""

Two rules keep generated scripts valid:

  • No square brackets in narration. [ starts a tag, so a stray bracket in your data ("the score was [3-1]") makes the script invalid. Replace them, as clean() does.
  • Keep narration in one block per speaker. One [tts] block per voice gives a single natural take. For conversations or Reddit-style threads, use [social-post] and [social-comment], each with its own voice. The voice design case study shows a finished example.

Before running a new template in bulk, check it with POST /scripts/validate. It reports syntax errors with line numbers, unknown tags and misspelled parameters, and estimates the credits, without rendering anything. POST /videos runs the same check and rejects an invalid script with a list of what is wrong, before anything is charged.

You can also ask an LLM to write the script. Give it the DSL reference, the format you want and the topic, then check the output: the facts, the hook and the tags. The formats guide has a workflow for this.

Rendering in Batches

import csv

with open("facts.csv") as f:
    rows = list(csv.DictReader(f))

task_ids = []
for i, row in enumerate(rows):
    r = requests.post(f"{BASE}/videos", headers=H, json={
        "script": fact_video(row["fact"]),
        "video_name": f"fact-{i:03d}",
        "estimated_duration": 20,
    })
    if r.status_code == 429:          # rate limited: wait and retry once
        time.sleep(60)
        r = requests.post(f"{BASE}/videos", headers=H, json={"script": fact_video(row["fact"]), "video_name": f"fact-{i:03d}", "estimated_duration": 20})
    r.raise_for_status()
    task_ids.append(r.json()["task_id"])
    time.sleep(7)                     # stay under 10 submissions a minute

Plan around the submission limits: 10 videos a minute, 25 an hour and 50 a day per account. A batch of 30 therefore takes a little over an hour to submit. Store every task_id as you go, in a file or database, so a crash halfway through does not lose track of what was already submitted.

Publishing Automatically

Add publish targets to the request and the video is uploaded once it is ready. List your connected channels with GET /channels to get their IDs. Set ai_generate_meta to have InkSlop write the title, description and tags from the script:

r = requests.post(f"{BASE}/videos", headers=H, json={
    "script": fact_video("Octopuses have three hearts and blue blood."),
    "ai_generate_meta": True,
    "publish": [
        {"channel_id": 3, "privacy": "public", "playlist_ids": ["PLxxxxxxxx"]},
        {"channel_id": 5},
    ],
})

Or render first, check the video, and publish later with a schedule:

requests.post(f"{BASE}/videos/{task_id}/publish", headers=H, json={
    "channels": [{
        "channel_id": 3,
        "title": "Why octopuses have three hearts",
        "privacy": "public",
        "scheduled_at": "2026-10-01T15:00:00Z",
    }],
})

Credits for any Creative Commons music or footage from the library are added to the YouTube description automatically, either way. The upload happens whether or not your code is still polling.

If you publish at volume, read the faceless channel guide first. YouTube does not monetise channels that look mass-produced, so vary the structure and give every video a reason to exist.

Getting Notified With Webhooks

Polling works, but for long batches it is simpler to let InkSlop tell you. Webhooks are set up once, in the dashboard rather than through the API:

  1. Go to Dashboard → Accounts → Webhooks and click Add endpoint.
  2. Enter your server's URL (a public https:// address) and pick the events, usually both Video completed and Video failed.
  3. Copy the signing secret (whsec_...) into your server's configuration.
  4. Click Send test and check the delivery log to see that your server answered.

Keeping this in the dashboard is deliberate: your API key can render and publish videos, but it cannot change where your webhooks go or read their secret, so a leaked key cannot redirect them.

From then on, every finished or failed video sends a signed POST to that URL with the task ID, the download URL and the credits used. Webhooks follow the Standard Webhooks specification, so verifying one is a few lines with the official library:

import os
from fastapi import FastAPI, Request, HTTPException
from standardwebhooks.webhooks import Webhook

app = FastAPI()
wh = Webhook(os.environ["INKSLOP_WEBHOOK_SECRET"])   # the whsec_... secret from the dashboard

@app.post("/inkslop-webhook")
async def inkslop_webhook(request: Request):
    body = await request.body()
    try:
        event = wh.verify(body, dict(request.headers))
    except Exception:
        raise HTTPException(400, "bad signature")
    if event["type"] == "video.completed":
        save_video(event["data"]["task_id"], event["data"]["download_url"])
    return {"ok": True}

Reply quickly with a 2xx status and do slow work afterwards. Failed deliveries are retried for about three days, and the same webhook-id header on every retry lets you ignore duplicates. If deliveries fail, the dashboard's delivery log shows each attempt and your server's response, and lets you resend. The API reference covers the events, retries and secret rotation.

Your Own Images and Footage

Upload a file once and reference it like a library file:

with open("chart.png", "rb") as f:
    ref = requests.post(f"{BASE}/files", headers=H, files={"file": f}).json()["ref"]

script = f"""
[video file="video/minecraft/BXUA2FncVPI.mp4" size="content" fit="loop"]
  [tts engine="kokoro_tts" voice="af_heart" subtitles="true"]
    Here is how the fees add up. [image file="{ref}" duration="3s"/] Same fund, different fee.
  [/tts]
[/video]
"""

Uploads count towards your plan's storage.

What It Costs

API videos cost exactly the same credits as videos made in the editor: rendering time, plus narration by the character, plus any effects, AI images or AI clips, rounded up once per video. A 60-second video with a standard voice is 3 credits. The cost breakdown covers other setups.

The finished task reports credits_used, so you can log the real cost of every video. If your balance cannot cover a video, the submission is rejected with 402 before anything renders.

Errors and Retries

Status What it means What to do
400 The script is invalid detail.errors lists each problem with its line
401 Key missing, wrong or revoked Check the Authorization header
402 Not enough credits Top up or wait for the monthly credits
409 The video is not finished yet (when publishing) Keep polling, then retry
422 The request body has the wrong shape The response lists each field that is wrong
429 Rate limit reached Wait and retry; slow the loop down

A task that ends with status error includes a message, and sends a video.failed webhook. Most script problems are caught before rendering, so errors at this stage are rare.

Things to Plan Around

  • Submission limits: 10 videos a minute, 25 an hour and 50 a day per account.
  • Webhooks are set up in the dashboard, not with your API key, and endpoints must be public HTTPS URLs. For local development, expose your receiver with a tunnel.
  • Video results are kept as files in your library, which count towards storage like any other generated video.
  • Voice-designed and cloned voices cost more per character than the standard Kokoro voices. Mix them only where a character needs it.

Create an API key and render your first video with the example above.

Related articles

Ready to create your own videos?