REST API · v1

Koopi Developer API

Build on top of Koopi. The REST API powers the viewer experience, the creator upload pipeline, and platform automation over predictable JSON endpoints.

Introduction

The Koopi API is organized around REST. It has predictable resource-oriented URLs, accepts and returns JSON-encoded payloads, and uses standard HTTP response codes, verbs, and authentication. All endpoints are served from the same origin as the application.

Resource-oriented

Predictable URLs grouped by resource such as series, uploads, and games.

JSON everywhere

Requests and responses use JSON with UTF-8 encoding.

Token & session auth

Use a scoped API token for automation, or a secure session cookie in the browser.

Standard semantics

Conventional HTTP verbs and status codes for every operation.

Base URL

All API routes are relative to your Koopi deployment origin. Prefix every path below with your base URL.

https://koopi.tv/api

Authentication

Koopi supports two ways to authenticate. In the browser, requests use a secure, HTTP-only session cookie issued at sign-in. For scripts, CI pipelines, and integrations, use an API token sent as a Bearer credential. Each endpoint declares one of three access levels:

Public

No authentication required.

User

Requires a signed-in viewer. Returns 401 otherwise.

Creator

Requires a creator account. Authenticate with a session cookie or an API token holding the required scope. Returns 401 without valid credentials and 403 when the account is not a creator or the token is missing a required scope.

API tokens

Creators can mint API tokens from the token dashboard or the Creator Hub. A token is shown only once at creation — Koopi stores just a hash, so copy it immediately. Pass it in the Authorization header:

curl https://koopi.tv/api/creator-hub/series \
  -H "Authorization: Bearer koopi_sk_xxxxxxxxxxxxxxxxxxxx"

Each token is granted one or more scopes. A request to a creator endpoint is rejected with 403 if the token lacks the scope that endpoint requires. Tokens may optionally expire and can be revoked at any time.

ScopeGrants
readRead your series, episodes, and performance data.
uploadRequest pre-signed upload URLs and push new video/image assets.
writeRegister episodes and update content metadata.

Requests & Responses

Send request bodies as JSON with a Content-Type: application/json header. Path parameters are shown in braces, for example /api/creator-hub/series/{id}. Successful responses return a 2xx status with a JSON body.

Errors

Koopi uses conventional HTTP status codes. Failures return a JSON body with an error message.

StatusMeaning
200 / 201The request succeeded.
400Malformed request or missing required parameters.
401Not authenticated — sign in and retry.
403Authenticated but lacking the required (creator) access.
404The requested resource does not exist.
429Too many requests — slow down and retry later.
5xxAn unexpected error occurred on the server.
401 Unauthorized
{
  "error": "Unauthorized"
}

Rate Limits

Endpoints are rate limited to protect platform stability. If you exceed the limit you will receive a429 response; back off and retry with exponential delay. Upload part-signing requests are batched to minimize round trips.

API Reference

Leaderboard

Public ranking data for games and creators.

GET/api/leaderboardPublic

Return the top-ranked entries. Supports optional filtering by game.

ParameterTypeDescription
gameIdstringRestrict results to a single game.
limitintegerMaximum entries to return (default 50).

Series & Episodes

Creator-scoped read access to the series catalog and their episodes.

GET/api/creator-hub/seriesCreator

List all series owned by the authenticated creator.

GET/api/creator-hub/series/{id}Creator

Fetch a single series with its metadata.

ParameterTypeDescription
id*stringSeries document id (path parameter).
GET/api/creator-hub/series/{id}/episodesCreator

List every episode belonging to a series, including processing status.

ParameterTypeDescription
id*stringSeries document id (path parameter).
DELETE/api/creator-hub/episode/{id}Creator

Delete an episode. Used to abandon a source that failed or is still processing.

ParameterTypeDescription
id*stringEpisode document id (path parameter).
PATCH/api/creator-hub/series/{id}Creator

Update series metadata, including the portrait thumbnail and the wide "hero banner" backdrop shown on the series page. Only fields present in the body are changed.

ParameterTypeDescription
id*stringSeries document id (path parameter).
titlestringSeries title.
descriptionstringSynopsis.
genresstring[]Genre tags.
languagestringPrimary language.
statusstring`ongoing`, `completed`, or `coming-soon`.
thumbnailS3UrlstringPortrait card art — the public URL returned by `presign-image`.
posterS3UrlstringHero banner / wide backdrop — the public URL returned by `presign-image`.
posterFocalPointstringJSON-encoded `{x,y}` percentage focal point for the backdrop crop.
curl -X PATCH https://koopi.tv/api/creator-hub/series/series_1a2b... \
  -H "Authorization: Bearer koopi_sk_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "posterS3Url": "https://cdn.koopi.tv/koopi-series/the-hidden-heir/the-hidden-heir-backdrop.jpg" }'

→ 200 OK
{ "ok": true, "id": "series_1a2b..." }

Media Uploads

Upload video sources and thumbnails to storage, then register them for transcoding. Large files (over 100 MB) use S3 multipart uploads for resilient, parallel transfer up to 200 GB.

POST/api/creator-hub/presignCreator

Get a pre-signed PUT URL for a single-request upload of a small video file.

ParameterTypeDescription
seriesId*stringOwning series.
episodeTitle*stringTitle used to derive the object key.
filename*stringOriginal file name.
contentType*stringMIME type, e.g. `video/mp4`.
curl -X POST https://koopi.tv/api/creator-hub/presign \
  -H "Authorization: Bearer koopi_sk_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "seriesId": "series_1a2b...",
    "seriesSlug": "the-hidden-heir",
    "seasonNumber": 1,
    "episodeNumber": 1,
    "episodeTitle": "A Storm Arrives",
    "filename": "ep01.mp4",
    "contentType": "video/mp4"
  }'

→ 200 OK
{
  "url": "https://<bucket>.s3.amazonaws.com/...(pre-signed)...",
  "key": "koopi-series/the-hidden-heir/s01/ep01-a-storm-arrives/ep01.mp4"
}

# PUT the raw file to the returned URL:
curl -X PUT "<url from above>" -H "Content-Type: video/mp4" --data-binary @ep01.mp4
POST/api/creator-hub/presign-imageCreator

Get a pre-signed PUT URL for uploading a thumbnail or cover image.

POST/api/creator-hub/multipart/createCreator

Begin a multipart upload. Returns the object key and an uploadId for the session.

POST /api/creator-hub/multipart/create
{
  "seriesId": "series_1a2b...",
  "episodeTitle": "Beard Talk — Episode 4",
  "filename": "beard-talk-04.mp4",
  "contentType": "video/mp4",
  "isLongForm": true
}

→ 200 OK
{
  "key": "koopi-series/.../beard-talk-04.mp4",
  "uploadId": "2~aBc..."
}
POST/api/creator-hub/multipart/sign-partsCreator

Return pre-signed PUT URLs for a batch of part numbers within an active upload.

ParameterTypeDescription
key*stringObject key from `create`.
uploadId*stringMultipart upload id.
partNumbers*integer[]Part numbers to sign (1-indexed).
POST/api/creator-hub/multipart/completeCreator

Finalize a multipart upload. Part ETags are collected server-side, so no ETag headers are required from the client.

ParameterTypeDescription
key*stringObject key from `create`.
uploadId*stringMultipart upload id.
expectedParts*integerNumber of parts uploaded, used to validate completeness.
POST/api/creator-hub/multipart/abortCreator

Cancel an in-progress multipart upload and discard any uploaded parts.

ParameterTypeDescription
key*stringObject key from `create`.
uploadId*stringMultipart upload id.
POST/api/creator-hub/register-episodeCreator

Register an uploaded source as an episode and submit it to the transcoding pipeline. Playback becomes available once processing completes.

ParameterTypeDescription
seriesId*stringOwning series.
s3Key*stringObject key of the uploaded source.
episodeTitle*stringDisplay title.
episodeNumberintegerRequired unless `isLongForm`.
seasonNumberintegerCreated automatically if it doesn’t exist yet.
episodeDescriptionstringSynopsis.
languagesstring[]Spoken/subtitle languages.
thumbnailS3KeystringPublic URL returned by `presign-image` for this episode’s thumbnail.
publishAtstring (ISO 8601)When the episode goes live. Omit to leave it unpublished.
isLongFormbooleanWhether this is a long-form source for the clipper.
curl -X POST https://koopi.tv/api/creator-hub/register-episode \
  -H "Authorization: Bearer koopi_sk_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "seriesId": "series_1a2b...",
    "seriesSlug": "the-hidden-heir",
    "s3Key": "koopi-series/the-hidden-heir/s01/ep01-a-storm-arrives/ep01.mp4",
    "seasonNumber": 1,
    "episodeNumber": 1,
    "episodeTitle": "A Storm Arrives",
    "episodeDescription": "Mina discovers the truth about her father.",
    "languages": ["English"],
    "thumbnailS3Key": "https://cdn.koopi.tv/koopi-series/the-hidden-heir/ep01-thumb.jpg",
    "publishAt": "2026-12-01T09:00:00Z"
  }'

→ 200 OK
{ "success": true, "episodeId": "episode_3c4d...", "muxAssetId": "aBc123..." }
POST/api/creator-hub/register-episodesCreator

Bulk form of register-episode: register and submit up to 50 already-uploaded episodes in a single call. Series ownership is checked once for the whole batch; each episode is then validated and registered independently — one bad row doesn’t abort the rest. This is what a CSV-driven import should call once a batch’s videos (and thumbnails) have been uploaded via presign / presign-image.

ParameterTypeDescription
seriesId*stringOwning series — shared by every episode in the batch.
seriesSlugstringOwning series slug (either seriesId or seriesSlug resolves ownership).
episodes*object[]Up to 50 episodes, each with the same fields as register-episode’s body (minus seriesId/seriesSlug, which are hoisted to the top level).
curl -X POST https://koopi.tv/api/creator-hub/register-episodes \
  -H "Authorization: Bearer koopi_sk_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "seriesId": "series_1a2b...",
    "seriesSlug": "the-hidden-heir",
    "episodes": [
      {
        "s3Key": "koopi-series/the-hidden-heir/s01/ep01-a-storm-arrives/ep01.mp4",
        "seasonNumber": 1,
        "episodeNumber": 1,
        "episodeTitle": "A Storm Arrives",
        "languages": ["English"],
        "publishAt": "2026-12-01T09:00:00Z"
      },
      {
        "s3Key": "koopi-series/the-hidden-heir/s01/ep02-the-confrontation/ep02.mp4",
        "seasonNumber": 1,
        "episodeNumber": 2,
        "episodeTitle": "The Confrontation"
      }
    ]
  }'

→ 200 OK
{
  "results": [
    { "index": 0, "success": true, "episodeId": "episode_3c4d...", "muxAssetId": "aBc123..." },
    { "index": 1, "success": true, "episodeId": "episode_5e6f...", "muxAssetId": "dEf456..." }
  ],
  "succeeded": 2,
  "failed": 0
}

Bulk importing episodes from a CSV

Video and thumbnail bytes still go up individually — each needs its own presign + PUT, since binary can’t ride inside JSON — but registration doesn’t have to: upload every file first, build one episodes[] array, then call register-episodes once for the whole batch (max 50 per call; chunk larger CSVs into multiple calls). A hero_banner_filename column names a backdrop image applied once to the series via the series PATCH endpoint above — not per episode. A raw file has no id until it’s uploaded — the S3 key is derived from its episode’s metadata, not the other way around — so the CSV’s *_filename columns, matched against your local files by exact name, are the only link between a row and its asset before that upload happens. Keep every filename in a batch unique (the Creator Hub UI’s own CSV import refuses to guess when two files share a name, rather than risk attaching the wrong one). Download the same template partners can use:

episode-bulk-import-template.csv
import { readFileSync } from 'fs'
import { parse } from 'csv-parse/sync' // any CSV library that handles quoted fields

const BASE = 'https://koopi.tv/api/creator-hub'
const TOKEN = process.env.KOOPI_API_TOKEN
const SERIES_ID = process.env.KOOPI_SERIES_ID
const SERIES_SLUG = 'the-hidden-heir'
const DIR = './episode-files' // local folder holding the files named in the CSV
const BATCH_SIZE = 50 // register-episodes' per-call cap

const headers = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }
const rows = parse(readFileSync('./episodes.csv'), { columns: true, skip_empty_lines: true })

// 1. Upload every video (and thumbnail, if given) — these can't be batched,
//    each file needs its own presigned PUT — and build the episode payload
//    register-episodes expects for each row.
const episodes = []
for (const row of rows) {
  const presign = await fetch(`${BASE}/presign`, {
    method: 'POST', headers,
    body: JSON.stringify({
      seriesId: SERIES_ID, seriesSlug: SERIES_SLUG,
      seasonNumber: row.season ? Number(row.season) : undefined,
      episodeNumber: Number(row.episode_number),
      episodeTitle: row.title,
      filename: row.video_filename,
      contentType: 'video/mp4',
    }),
  }).then(r => r.json())
  await fetch(presign.url, { method: 'PUT', body: readFileSync(`${DIR}/${row.video_filename}`), headers: { 'Content-Type': 'video/mp4' } })

  let thumbnailS3Key
  if (row.thumbnail_filename) {
    const thumb = await fetch(`${BASE}/presign-image`, {
      method: 'POST', headers,
      body: JSON.stringify({ key: `${SERIES_SLUG}/ep${row.episode_number}-thumb.jpg`, contentType: 'image/jpeg' }),
    }).then(r => r.json())
    await fetch(thumb.url, { method: 'PUT', body: readFileSync(`${DIR}/${row.thumbnail_filename}`), headers: { 'Content-Type': 'image/jpeg' } })
    thumbnailS3Key = thumb.publicUrl
  }

  episodes.push({
    s3Key: presign.key,
    seasonNumber: row.season ? Number(row.season) : undefined,
    episodeNumber: Number(row.episode_number),
    episodeTitle: row.title,
    episodeDescription: row.description || undefined,
    languages: row.languages ? row.languages.split(';') : undefined,
    thumbnailS3Key,
    publishAt: row.publish_at || new Date().toISOString(),
  })
}

// 2. Register the whole batch (chunked to the per-call cap) in one request
//    per chunk, instead of one register-episode call per row.
for (let i = 0; i < episodes.length; i += BATCH_SIZE) {
  const chunk = episodes.slice(i, i + BATCH_SIZE)
  const { results, succeeded, failed } = await fetch(`${BASE}/register-episodes`, {
    method: 'POST', headers,
    body: JSON.stringify({ seriesId: SERIES_ID, seriesSlug: SERIES_SLUG, episodes: chunk }),
  }).then(r => r.json())

  console.log(`Batch ${i / BATCH_SIZE + 1}: ${succeeded} registered, ${failed} failed`)
  results.filter(r => !r.success).forEach(r => console.error(`  row ${i + r.index}: ${r.error}`))
}

// Hero banner applies once to the series, not per episode.
const heroFilename = rows.map(r => r.hero_banner_filename).find(Boolean)
if (heroFilename) {
  const hero = await fetch(`${BASE}/presign-image`, {
    method: 'POST', headers,
    body: JSON.stringify({ key: `${SERIES_SLUG}/${SERIES_SLUG}-backdrop.jpg`, contentType: 'image/jpeg' }),
  }).then(r => r.json())
  await fetch(hero.url, { method: 'PUT', body: readFileSync(`${DIR}/${heroFilename}`), headers: { 'Content-Type': 'image/jpeg' } })
  await fetch(`${BASE}/series/${SERIES_ID}`, { method: 'PATCH', headers, body: JSON.stringify({ posterS3Url: hero.publicUrl }) })
}

AI Clipper

Analyze a long-form source and generate short vertical clips from it.

POST/api/creator-hub/clipper/analyze-cutsCreator

Detect candidate cut points and highlight moments in a source.

POST/api/creator-hub/clipper/analyze-segmentsCreator

Group transcript segments into coherent clip suggestions.

POST/api/creator-hub/clipper/analyze-framingCreator

Suggest vertical framing / crop windows for a clip.

GET/api/creator-hub/clipper/shortsCreator

List generated shorts for a source.

DELETE/api/creator-hub/clipper/shorts/{id}Creator

Delete a generated short.

ParameterTypeDescription
id*stringShort document id (path parameter).

Games

Upload, review, and play creator-submitted game bundles. Uploading is done from Creator Hub, not a public API — see the Games tab. For wiring the in-game Coin SDK (requestAdBreak, spendCoins, getCoinBalance) into your bundle, see the Koopi Coin Integration Guide at /developers/game-coin-guide.

GET/api/games/bundle-urlUser

Resolve a signed, time-limited URL to load a game bundle.

POST/api/games/[id]/spendUser

Spend coins on an in-game item (ad-skip or a creator-defined store item). Called by the Koopi Game SDK, not directly.

Advertising

Serve advertisements and record impression / interaction telemetry.

GET/api/ads/randomPublic

Return a randomly selected eligible advertisement.

POST/api/ads/impressionPublic

Record that an ad was shown.

ParameterTypeDescription
adId*stringAdvertisement id.
POST/api/ads/eventPublic

Record an ad interaction such as a click or skip.

ParameterTypeDescription
adId*stringAdvertisement id.
type*stringEvent type, e.g. `click` or `skip`.

Webhooks

Inbound endpoints invoked by external services. These are not called by your integration — they are secured by provider signatures and documented here for completeness.

POST/api/webhooks/muxPublic

Receives transcoding lifecycle events (asset ready, errored, track ready). Verified with the Mux signing secret.

POST/api/webhooks/s3Public

Receives storage notifications for uploaded objects.

POST/api/stripe/webhookPublic

Receives Stripe payment and checkout events. Verified with the Stripe signing secret.