InkSlop API

The InkSlop API lets you generate and publish videos programmatically, from a single script or a batch of hundreds.

Base URL: https://inkslop.com/api/proxy/api/v1


Authentication

All endpoints require an API key passed as a Bearer token.

Authorization: Bearer isk_your_key_here

Create API keys in the Dashboard → Accounts tab. Keys are prefixed with isk_ and are shown once at creation, so store them securely.


API Keys

Key management requires a dashboard login (Logto session JWT). Requests authenticated with an API key get 403, so a leaked key cannot create new keys or revoke yours.

Create a key

POST /api/v1/keys
{ "name": "My automation" }

Response:

{
  "key_id": "a1b2c3...",
  "name": "My automation",
  "api_key": "isk_abcdef...",
  "created_at": "2026-05-18T12:00:00Z"
}

api_key is returned only once. Store it immediately.

List keys

GET /api/v1/keys
[
  {
    "key_id": "a1b2c3...",
    "name": "My automation",
    "created_at": "2026-05-18T12:00:00Z",
    "last_used_at": "2026-05-18T14:30:00Z",
    "revoked": false
  }
]

Revoke a key

DELETE /api/v1/keys/{key_id}
{ "revoked": true, "key_id": "a1b2c3..." }

Channels

List the YouTube and TikTok channels you have connected in the dashboard.

GET /api/v1/channels
[
  {
    "id": 3,
    "platform": "youtube",
    "account_name": "My Channel",
    "channel_id": "UCxxxxxxx",
    "is_oauth": true
  }
]

Use the id field when submitting videos for auto-publish.


Videos

Submit a video

POST /api/v1/videos
Field Type Description
script string DSL script. See DSL Reference.
video_name string Display name (no extension). Optional.
resolution string "9:16" (portrait, default) or "16:9"
estimated_duration float Duration hint in seconds (default 30). Used for the credit pre-check.
notify_on_complete bool Send an email when generation finishes.
publish array Auto-publish targets, one object per channel. Optional.
ai_generate_meta bool Generate a title, description and tags from the script, used for every publish entry that has no title of its own.
asset_files object Only needed for your own uploaded files (see below).

Assets. Library files referenced in the script (for example music/Carefree.opus or video/minecraft/BXUA2FncVPI.mp4) are found automatically, and so are your own files uploaded with POST /files: use the ref it returns, such as [image file="user/5f2c…a1.png" duration="3s"/]. You only need asset_files for files stored elsewhere.

Validation. The script is checked before anything is rendered or charged. Syntax errors, unknown tags and uploaded files that cannot be found return 400 with every problem listed:

{
  "detail": {
    "message": "The script is not valid. Nothing was rendered or charged.",
    "errors": [{ "message": "Unknown tag [shak]. …", "line": 3, "column": null }]
  }
}

Parameters that have no effect (usually typos such as speeed="2") do not block the render; they are listed in warnings in the response. Use POST /scripts/validate to check a script without submitting it.

Minimal request (generate only):

{
  "script": "[tts engine=\"kokoro_tts\" voice=\"af_heart\"]\nHello world!\n[/tts]",
  "video_name": "hello-world",
  "resolution": "9:16"
}

With manual publish metadata:

{
  "script": "...",
  "publish": [
    {
      "channel_id": 3,
      "title": "My Video",
      "description": "A video created with InkSlop.",
      "tags": ["ai", "shorts"],
      "privacy": "public"
    }
  ]
}

With AI-generated metadata, to two channels:

{
  "script": "...",
  "ai_generate_meta": true,
  "publish": [
    { "channel_id": 3, "privacy": "public" },
    { "channel_id": 5, "privacy": "public" }
  ]
}

Publish config fields:

Field Type Description
channel_id int Channel ID from GET /channels
title string Video title. Leave empty with ai_generate_meta: true.
description string Video description.
tags array List of tag strings.
privacy string public, private, or unlisted
review_before_publish bool Queue the upload for manual approval in the dashboard before it goes live.
scheduled_at string ISO 8601 time to publish at. Empty publishes as soon as the video is ready.
playlist_ids array YouTube only. Playlist IDs the video is added to after upload.

YouTube descriptions get the credits for any Creative Commons music or footage from the library appended automatically, followed by "Made with #InkSlop" unless you turned branding off in your account. TikTok uploads use the title; privacy maps to TikTok's public, followers-only (unlisted) and private (private) settings.

Response:

{
  "task_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "warnings": []
}

Free preview

POST /api/v1/videos/preview
GET  /api/v1/previews/{task_id}

Takes script, resolution and asset_files. Renders the first 30 seconds at 360p with an InkSlop watermark and free voices (paid voices are swapped for a free one, so timing is close but not exact) and AI images/videos replaced by their fallback or a placeholder. Nothing is charged. Poll the GET until status is done, then play video_url; previews are deleted after a day.

Previews count against an hourly allowance of preview time: 5 minutes on the free plan, 10 on Starter, 20 on Standard and 40 on Business. Each preview counts as its estimated narration length, at least 10 and at most 30 seconds, and stops counting an hour after it started. The response includes quota (used, limit, remaining, resets_in, in seconds). Over the allowance the call returns 429 with the same fields. Also limited to 5 a minute.

Render in several languages

POST /api/v1/videos/translations

Takes every field of Submit a video, plus:

Field Type Description
languages array Language codes, e.g. ["es", "fr", "de"] (up to 10). GET /api/v1/languages lists them.
include_original bool Also render the script as written.

The narration, [text] values and [chapter] titles are translated and the voices switched to native ones; tags are never changed (a translation that alters them is rejected). Each language costs 1 credit for the translation plus the video. If any translation fails, or the videos cost more than the credits available, nothing is rendered and the translation credits are refunded.

{ "videos": [{ "lang": "original", "task_id": "…" }, { "lang": "es", "task_id": "…" }], "warnings": [] }

Poll progress

GET /api/v1/videos/{task_id}
Status Meaning
pending Queued, not started yet
processing Rendering in progress
done Complete, video_url and download_url are available
error Failed, check message

Response (done):

{
  "task_id": "550e8400...",
  "status": "done",
  "progress": 100,
  "message": "Generation complete.",
  "video_url": "https://inkslop.com/files/abc123",
  "download_url": "https://inkslop.com/files/abc123/download",
  "credits_used": 3,
  "duration_seconds": 28.4,
  "user_file_id": 42
}

Poll every 5-10 seconds until status is done or error, or skip polling and receive a webhook instead. Publishing happens whether or not you poll.

List videos

GET /api/v1/videos?limit=20&offset=0

Returns an array of video summaries, newest first: task_id, video_name, status, resolution, created_at, credits_used, user_file_id, video_url, download_url, duration_seconds. limit max is 100.

Publish a completed video

POST /api/v1/videos/{task_id}/publish

The video must have status: done. Queues an upload to one or more YouTube or TikTok channels.

{
  "ai_generate_meta": false,
  "channels": [
    {
      "channel_id": 3,
      "title": "My Video",
      "description": "Description here.",
      "tags": ["shorts", "ai"],
      "privacy": "public",
      "scheduled_at": "2026-10-01T15:00:00Z",
      "playlist_ids": []
    }
  ]
}
Field Type Description
channels array One entry per channel. Required, must not be empty.
channels[].channel_id int Channel ID from GET /channels. The channel must be connected with OAuth.
channels[].title, description, tags, privacy As for submit.
channels[].scheduled_at string ISO 8601 time to publish at. Empty publishes immediately.
channels[].playlist_ids array YouTube only. Playlists to add the video to.
ai_generate_meta bool Generate metadata for entries without a title.

Response:

{
  "published": [
    { "platform": "youtube", "channel_id": 3, "job_id": 17, "status": "pending" }
  ]
}

Files

Upload your own images, footage and audio to use in scripts. Files count towards your storage quota.

Upload a file

POST /api/v1/files

Send the file as multipart/form-data in a field named file. Accepted: PNG, JPEG, WebP, MP4, MOV, WebM, MP3, WAV, OGG and Opus.

curl -X POST https://inkslop.com/api/proxy/api/v1/files \
  -H "Authorization: Bearer isk_your_key_here" \
  -F "[email protected]"
{
  "file_id": 42,
  "ref": "user/5f2c9e...a1.png",
  "file_name": "chart.png",
  "content_type": "image/png",
  "size_bytes": 48213,
  "created_at": "2026-09-29T12:00:00+00:00"
}

Use ref in scripts: [image file="user/5f2c9e...a1.png" duration="3s"/].

List files

GET /api/v1/files?limit=50&offset=0

Returns your uploaded files, newest first, in the same shape.


Validate a script

Check a script without rendering it or spending credits.

POST /api/v1/scripts/validate
{ "script": "[tts voice=\"af_heart\" speeed=\"2\"]Hello[/tts]", "estimated_duration": 30 }
{
  "valid": true,
  "errors": [],
  "warnings": [{ "message": "[tts] does not use the parameter \"speeed\", so it has no effect. Check the spelling.", "line": 1, "column": null }],
  "assets": [],
  "estimated_duration_seconds": 0.4,
  "estimated_credits": 1
}

errors covers syntax errors (with line and column), unknown tags and uploaded files that cannot be found. A script with errors has valid: false and would be rejected by POST /videos. estimated_credits is an estimate; the charge is based on the finished video.


Webhooks

Instead of polling, InkSlop can POST an event to your server when a video finishes or fails, or when publishing to a channel succeeds or fails. Webhooks follow the Standard Webhooks specification, so any Standard Webhooks library can verify them.

Events

Type When data
video.completed A video finished rendering task_id, video_name, status, duration_seconds, credits_used, user_file_id, video_url, download_url
video.failed A video could not be rendered task_id, video_name, status, error
publish.completed A video was published to a channel job_id, platform (youtube/tiktok), user_file_id, channel_id, title, status, scheduled_at, and video_id + url (YouTube) or publish_id (TikTok)
publish.failed Publishing failed after all retries as publish.completed, plus error
webhook.test You called the test endpoint endpoint_id, message

Example body:

{
  "type": "video.completed",
  "timestamp": "2026-09-29T12:04:10.344522+00:00",
  "data": {
    "task_id": "550e8400-e29b-41d4-a716-446655440000",
    "video_name": "mantis-shrimp",
    "status": "done",
    "duration_seconds": 14.2,
    "credits_used": 1,
    "user_file_id": 42,
    "video_url": "https://inkslop.com/files/abc123",
    "download_url": "https://inkslop.com/files/abc123/download"
  }
}

Set up an endpoint

Webhooks are managed in the dashboard, under Dashboard → Accounts → Webhooks, not through the API. An API key cannot create, change or read webhook endpoints, so a leaked key cannot redirect your webhooks or read their signing secret.

  1. Click Add endpoint and enter your URL. It must use https:// and point to a public address.
  2. Choose the events you want.
  3. Copy the signing secret (whsec_...) into your server's configuration. You can show it again later from the endpoint.

From the same place you can:

  • Send test to deliver a webhook.test event and check your server,
  • see recent deliveries with their status, attempts and last response, and Resend failed ones,
  • Rotate the secret (the old one keeps signing alongside the new one for 24 hours),
  • Disable, re-enable or delete the endpoint.

You can have up to 10 endpoints.

Verifying a webhook

Every request carries three headers:

Header Meaning
webhook-id Unique message ID. The same across retries: use it to ignore duplicates.
webhook-timestamp Unix time of this attempt. Reject requests more than 5 minutes old.
webhook-signature v1,<base64 signature>. During a secret rotation there are two, separated by a space.

The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret after whsec_. Always verify against the raw request body, before parsing the JSON.

import base64, hashlib, hmac, time

def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(
        hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
    ).decode()
    return any(
        hmac.compare_digest(sig.split(",", 1)[1], expected)
        for sig in headers["webhook-signature"].split(" ")
        if sig.startswith("v1,")
    )

Or use the official library: pip install standardwebhooks, then Webhook(secret).verify(raw_body, headers).

Delivery and retries

  • Reply with any 2xx status within 15 seconds to acknowledge a message. Do slow work after replying.
  • Anything else counts as a failure, including redirects, which are not followed.
  • Failed messages are retried after 5 seconds, 5 minutes, 30 minutes, 2, 5, 10, 14, 20 and 24 hours (with a little random jitter), about 3 days in total. A Retry-After header is respected.
  • Replying 410 Gone disables the endpoint immediately.
  • If 5 messages in a row fail every retry, the endpoint is disabled and you are emailed. Fix your server, then enable the endpoint again in the dashboard and resend failed messages from its delivery log.

AI Metadata

Generate a title, description, and tags for any script using AI.

POST /api/v1/meta/generate
{
  "script": "...",
  "platform": "youtube"
}

Response:

{
  "title": "5 Things Nobody Tells You About Electric Cars",
  "description": "We break down the real cost, range, and daily experience of owning an EV in 2026.",
  "tags": ["electric cars", "ev", "tesla", "sustainability", "auto"]
}

platform can be youtube (10 tags, longer description) or tiktok (5 tags, shorter).


Error handling

All errors return a JSON body with a detail field.

Status Meaning
400 Bad request, including an invalid script (detail.errors lists each problem)
401 Invalid or revoked API key
402 Insufficient credits or subscription required
404 Resource not found
409 Conflict, e.g. video not done yet
422 Request body does not match the expected shape (the detail lists each field)
429 Rate limit exceeded
502 External service unavailable (Polar, Google AI)

Example:

{ "detail": "Insufficient credits. Balance: 0.5, required: 2.0" }

Rate limits

Limits are per user across all API keys.

Endpoint Limit
POST /videos 10/min, 25/hr, 50/day
GET /videos/{id}, GET /videos, GET /channels, GET /keys 60/min, 500/hr, 2,000/day
POST /videos/{id}/publish 30/min, 300/hr, 1,000/day
POST /files 10/min, 25/hr, 50/day
POST /scripts/validate, GET /files 60/min, 500/hr, 2,000/day
POST /meta/generate 10/min, 60/hr
POST /keys, DELETE /keys/{id} 5/min, 100/hr, 200/day

When rate-limited, the response is 429 Too Many Requests.


Quick start example

import time, requests

BASE = "https://inkslop.com/api/proxy/api/v1"
KEY  = "isk_your_key_here"
H    = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

SCRIPT = """
[video file="video/minecraft/BXUA2FncVPI.mp4" size="content" fit="loop"]
  [music file="music/Carefree.opus" volume="0.3" fit="loop"]
    [tts engine="kokoro_tts" voice="af_heart" subtitles="true"]
      Did you know the mantis shrimp can punch with the force of a bullet?
      Their eyes have up to sixteen types of colour receptors. Humans have three.
    [/tts]
  [/music]
[/video]
"""

# Submit
r = requests.post(f"{BASE}/videos", headers=H, json={
    "script": SCRIPT,
    "video_name": "mantis-shrimp",
    "resolution": "9:16",
    "estimated_duration": 15,
})
r.raise_for_status()
task_id = r.json()["task_id"]
print("Task:", task_id)

# Poll until done
while True:
    r = requests.get(f"{BASE}/videos/{task_id}", headers=H)
    data = r.json()
    print(f"  {data['progress']}% - {data['message']}")
    if data["status"] in ("done", "error"):
        break
    time.sleep(8)

if data["status"] == "done":
    print("Download:", data["download_url"])
    print("Credits used:", data["credits_used"])

Ready to start building?