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.
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
- Get an API key
- Your first video
- Building scripts from data
- Rendering in batches
- Publishing automatically
- Getting notified with webhooks
- Your own images and footage
- What it costs
- Errors and retries
- Things to plan around
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_hereYour 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_durationis 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, asclean()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 minutePlan 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:
- Go to Dashboard → Accounts → Webhooks and click Add endpoint.
- Enter your server's URL (a public
https://address) and pick the events, usually both Video completed and Video failed. - Copy the signing secret (
whsec_...) into your server's configuration. - 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.