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/apiAuthentication
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:
No authentication required.
Requires a signed-in viewer. Returns 401 otherwise.
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.
| Scope | Grants |
|---|---|
| read | Read your series, episodes, and performance data. |
| upload | Request pre-signed upload URLs and push new video/image assets. |
| write | Register 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.
| Status | Meaning |
|---|---|
| 200 / 201 | The request succeeded. |
| 400 | Malformed request or missing required parameters. |
| 401 | Not authenticated — sign in and retry. |
| 403 | Authenticated but lacking the required (creator) access. |
| 404 | The requested resource does not exist. |
| 429 | Too many requests — slow down and retry later. |
| 5xx | An 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.
Leaderboard
Public ranking data for games and creators.
/api/leaderboardPublicReturn the top-ranked entries. Supports optional filtering by game.
| Parameter | Type | Description |
|---|---|---|
| gameId | string | Restrict results to a single game. |
| limit | integer | Maximum entries to return (default 50). |
Series & Episodes
Creator-scoped read access to the series catalog and their episodes.
/api/creator-hub/seriesCreatorList all series owned by the authenticated creator.
/api/creator-hub/series/{id}CreatorFetch a single series with its metadata.
| Parameter | Type | Description |
|---|---|---|
| id* | string | Series document id (path parameter). |
/api/creator-hub/series/{id}/episodesCreatorList every episode belonging to a series, including processing status.
| Parameter | Type | Description |
|---|---|---|
| id* | string | Series document id (path parameter). |
/api/creator-hub/episode/{id}CreatorDelete an episode. Used to abandon a source that failed or is still processing.
| Parameter | Type | Description |
|---|---|---|
| id* | string | Episode document id (path parameter). |
/api/creator-hub/series/{id}CreatorUpdate 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.
| Parameter | Type | Description |
|---|---|---|
| id* | string | Series document id (path parameter). |
| title | string | Series title. |
| description | string | Synopsis. |
| genres | string[] | Genre tags. |
| language | string | Primary language. |
| status | string | `ongoing`, `completed`, or `coming-soon`. |
| thumbnailS3Url | string | Portrait card art — the public URL returned by `presign-image`. |
| posterS3Url | string | Hero banner / wide backdrop — the public URL returned by `presign-image`. |
| posterFocalPoint | string | JSON-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.
/api/creator-hub/presignCreatorGet a pre-signed PUT URL for a single-request upload of a small video file.
| Parameter | Type | Description |
|---|---|---|
| seriesId* | string | Owning series. |
| episodeTitle* | string | Title used to derive the object key. |
| filename* | string | Original file name. |
| contentType* | string | MIME 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/api/creator-hub/presign-imageCreatorGet a pre-signed PUT URL for uploading a thumbnail or cover image.
/api/creator-hub/multipart/createCreatorBegin 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..."
}/api/creator-hub/multipart/sign-partsCreatorReturn pre-signed PUT URLs for a batch of part numbers within an active upload.
| Parameter | Type | Description |
|---|---|---|
| key* | string | Object key from `create`. |
| uploadId* | string | Multipart upload id. |
| partNumbers* | integer[] | Part numbers to sign (1-indexed). |
/api/creator-hub/multipart/completeCreatorFinalize a multipart upload. Part ETags are collected server-side, so no ETag headers are required from the client.
| Parameter | Type | Description |
|---|---|---|
| key* | string | Object key from `create`. |
| uploadId* | string | Multipart upload id. |
| expectedParts* | integer | Number of parts uploaded, used to validate completeness. |
/api/creator-hub/multipart/abortCreatorCancel an in-progress multipart upload and discard any uploaded parts.
| Parameter | Type | Description |
|---|---|---|
| key* | string | Object key from `create`. |
| uploadId* | string | Multipart upload id. |
/api/creator-hub/register-episodeCreatorRegister an uploaded source as an episode and submit it to the transcoding pipeline. Playback becomes available once processing completes.
| Parameter | Type | Description |
|---|---|---|
| seriesId* | string | Owning series. |
| s3Key* | string | Object key of the uploaded source. |
| episodeTitle* | string | Display title. |
| episodeNumber | integer | Required unless `isLongForm`. |
| seasonNumber | integer | Created automatically if it doesn’t exist yet. |
| episodeDescription | string | Synopsis. |
| languages | string[] | Spoken/subtitle languages. |
| thumbnailS3Key | string | Public URL returned by `presign-image` for this episode’s thumbnail. |
| publishAt | string (ISO 8601) | When the episode goes live. Omit to leave it unpublished. |
| isLongForm | boolean | Whether 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..." }/api/creator-hub/register-episodesCreatorBulk 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.
| Parameter | Type | Description |
|---|---|---|
| seriesId* | string | Owning series — shared by every episode in the batch. |
| seriesSlug | string | Owning 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.csvimport { 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.
/api/creator-hub/clipper/analyze-cutsCreatorDetect candidate cut points and highlight moments in a source.
/api/creator-hub/clipper/analyze-segmentsCreatorGroup transcript segments into coherent clip suggestions.
/api/creator-hub/clipper/analyze-framingCreatorSuggest vertical framing / crop windows for a clip.
/api/creator-hub/clipper/shortsCreatorList generated shorts for a source.
/api/creator-hub/clipper/shorts/{id}CreatorDelete a generated short.
| Parameter | Type | Description |
|---|---|---|
| id* | string | Short 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.
/api/games/bundle-urlUserResolve a signed, time-limited URL to load a game bundle.
/api/games/[id]/spendUserSpend 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.
/api/ads/randomPublicReturn a randomly selected eligible advertisement.
/api/ads/impressionPublicRecord that an ad was shown.
| Parameter | Type | Description |
|---|---|---|
| adId* | string | Advertisement id. |
/api/ads/eventPublicRecord an ad interaction such as a click or skip.
| Parameter | Type | Description |
|---|---|---|
| adId* | string | Advertisement id. |
| type* | string | Event 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.
/api/webhooks/muxPublicReceives transcoding lifecycle events (asset ready, errored, track ready). Verified with the Mux signing secret.
/api/webhooks/s3PublicReceives storage notifications for uploaded objects.
/api/stripe/webhookPublicReceives Stripe payment and checkout events. Verified with the Stripe signing secret.