# Clipped API > Watch streamers, read the clips Clipped cuts from their streams, and post the good ones. Base address https://app.clipped.so/api/v1. Every request carries "Authorization: Bearer "; keys are made at https://app.clipped.so/settings/api. An MCP server with the same abilities is at https://app.clipped.so/api/mcp. ## Endpoints - GET /api/v1/me: The account, its plan and allowance, the rate limits, and the lineup. - GET /api/v1/streamers: Who you are watching. streamers is the names; lineup is each with whether they are live and their rules. - POST /api/v1/streamers: Start watching one. { "channel": "jynxzi" }. - GET /api/v1/streamers/{channel}: One streamer: live or not, and their rules. - PATCH /api/v1/streamers/{channel}: Give them rules of their own. autoPost, ending, captions, captionPosition, taste; null for the account's. - DELETE /api/v1/streamers/{channel}: Stop watching one. Recording stops now; clips already cut are kept. - GET /api/v1/settings: Every setting, and which accounts are connected where. - PATCH /api/v1/settings: Change some of them. Answers with every setting as it now is. - GET /api/v1/clips: Clips, oldest first. cursor, channel, limit (1–100, default 25). No cursor: the newest few, and a cursor to poll from. - GET /api/v1/clips/{id}: One clip. - GET /api/v1/clips/{id}/video: The mp4. Supports Range. 202 with Retry-After while it is being cut. - GET /api/v1/clips/{id}/thumbnail: Its thumbnail. Redirects (302) to the image, making it first if there is none. 202 with Retry-After while it can't be made yet. - POST /api/v1/clips/{id}/publish: Post it. destination is youtube, tiktok or instagram. - GET /api/v1/webhooks: Your webhooks, and each one's last delivery. - POST /api/v1/webhooks: Make one. { url, events?, channels? }. Up to five. - PATCH /api/v1/webhooks/{id}: Change one, or switch it back on. { url?, events?, channels?, active? }. - POST /api/v1/webhooks/{id}/test: Send it a ping now. Answers with how the URL responded. - DELETE /api/v1/webhooks/{id}: Delete one. - GET /api/v1/streams: Each streamer you follow, whether a recording of them is running, and their three feed addresses. - WS /api/v1/streams/{channel}/analysis: The signals, second by second, and the clips found. ?audio=false leaves out the 10-a-second audio meters. - WS /api/v1/streams/{channel}/transcript: What the streamer says, a segment at a time, with each word's timing. - WS /api/v1/streams/{channel}/chat: Every chat message, and each one's score once it has one. - GET /api/v1/analytics: ?days= 1–365 (28 by default), ?channel= for one streamer. Answers { report, reflections }. ## Getting started Everything is authenticated with an API key you make yourself under API & agents in the app. A key acts as you: it sees your recordings and nobody else's, it can change your settings, and it is limited by your plan. Treat it like a password; revoke it there if it leaks. Send it as a bearer token on every request. X-Api-Key works too, for clients that only offer that. Bodies are JSON, and so is every answer. GET /api/v1/me is the call to make first: it says which account the key is for, what the plan allows, how much of it is used, and who you are watching. Who am I, and what may I do?: ```bash curl https://app.clipped.so/api/v1/me \ -H "Authorization: Bearer clip_sk_…" ``` The answer: ```json { "account": { "id": "8f1c…", "email": "you@example.com" }, "plan": { "name": "Pro", "channelsAtOnce": 5, "streamHours": 300, "streamHoursUsed": 41.5, "status": "active" }, "limits": { "perMinute": 120, "perDay": 20000 }, "following": ["jynxzi", "kick:xqc"] } ``` ## A whole setup in five calls What most integrations do, start to finish: watch a streamer, decide what posts itself, hear about clips as they are found, and post the ones that did not post themselves. Connecting a YouTube, TikTok or Instagram account is the one thing a key cannot do — it is a sign-in the account's owner does once, in the browser, under Settings → Accounts. Everything after that can be done from here. ```bash KEY=clip_sk_… # 1. Watch a streamer. They are recorded whenever they go live from now on. curl -X POST https://app.clipped.so/api/v1/streamers -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "channel": "jynxzi" }' # 2. Let their best clips post themselves: anything scoring 80 or more. curl -X PATCH https://app.clipped.so/api/v1/streamers/jynxzi -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "autoPost": { "on": true, "minScore": 80 } }' # 3. Cut everything vertical with captions, and post at most six a day. curl -X PATCH https://app.clipped.so/api/v1/settings -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "format": "blur-fill", "captions": true, "postsPerDay": 6 }' # 4. Be told when a clip is ready (or poll /api/v1/clips with a cursor instead). curl -X POST https://app.clipped.so/api/v1/webhooks -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "url": "https://example.com/clipped", "events": ["clip.ready"] }' # 5. Post one by hand. curl -X POST "https://app.clipped.so/api/v1/clips/$CLIP_ID/publish" -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "destination": "youtube" }' ``` ## Streamers Watching a streamer is what makes Clipped record them whenever they go live, with nobody at a browser. It is the thing that produces clips, so it is usually the first call an integration makes. A channel is a name or a link on Twitch, Kick or YouTube. A bare name is Twitch; the others are written kick:xqc and youtube:@handle, or pasted as a link. In a path, URL-encode it: /api/v1/streamers/kick%3Axqc. Each streamer can have rules of their own — whether their clips post themselves, whether those get captions, how long their clips run, what makes a moment on their stream — which beat the account's settings. null in a PATCH puts one back on the account's. Rules can be set before you watch someone, so they are in place for their first stream. A free account cannot keep a lineup — it opens one channel at a time from the web app — so watching needs a paid plan and answers 402 plan_required without one. How many are recorded at once is the plan's channelsAtOnce. ### A streamer - channel (string): The channel's key: jynxzi, kick:xqc, youtube:@handle. - following (boolean): Whether it is on your lineup. - live (object | null): The recording running now — sessionId, title, game, startedAt (ms), moments found so far — or null. - autoPost ({ on, minScore }): Whether their clips post themselves, and from what score. - ending (string): tight, balanced or loose: how long a clip runs past the moment. - captions (boolean): Whether their clips that post themselves get captions burnt in. - captionPosition (string): Where those go: auto, top or bottom. Their own once one of their clips is captioned by hand, which also keeps that clip's font and colours for them. - taste (object): What makes a moment on their stream. See Settings. - own (object): { autoPost, ending, captions, captionPosition, taste }: which are the streamer's own rather than the account's. Tight clips for one streamer, and back to the default auto-post rule: ```bash curl -X PATCH https://app.clipped.so/api/v1/streamers/kick%3Axqc \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{ "ending": "tight", "autoPost": null }' ``` ## Settings One object holds everything the account has chosen, and a PATCH changes only the fields it names. The whole patch is checked before anything is saved, so one bad field changes nothing and the error says which. A field that isn't a setting is refused rather than ignored. These are the defaults for every streamer. A streamer's own rules beat them, and a change here reaches every running recording that follows the default from its next clip. ### Settings - autoPost ({ on, minScore }): Whether clips post themselves by default, and the score (1–100) they must reach. See Auto-posting. - categoryRules (array): Auto-posting for one game or genre, whoever is playing: { kind: "game" | "genre", value, on, minScore }. A PATCH replaces the whole list. - accountContent (object): Per connected account id: the kind of content it posts, as [{ kind: "game" | "genre", value }]. A clip posting itself goes only to an account that takes its game. null or [] takes any. Each account's is also under destinations, as content. - postsPerDay (integer): The most clips that post themselves in a day, 0–24. - privacy (string): public, unlisted or private: what uploads are posted as. - accountPrivacy (object): PATCH only. { "": "public" } gives one connected account its own privacy; null puts it back. - format (string): source (as broadcast), vertical (9:16 gameplay crop) or blur-fill (the whole frame over a blurred 9:16 copy — the vertical shape that needs no facecam found). - captions (boolean): Burn captions into clips that post themselves. - captionPosition (string): Where those captions go: auto (each line wherever is clearest), top or bottom. - ending (string): tight (~33s), balanced (~35s) or loose (~41s). - taste (object): Five dials, each an integer from -2 to 2, 0 being Clipped's default: voice (chat ↔ the streamer), chat (how many are talking ↔ how they feel), streamer (how loud ↔ what they say), review and topic. Send all five. - titleTemplate (string): {headline} is the written title; also {quote}, {hook}, {channel}, {game}, {category}, {date}. - descriptionTemplate (string): The same fields, for the line the description opens with. - tags (string[]): Added to every upload, up to 20. - destinations (array): Read-only. Per destination: connected, and its accounts — id, name, whether it is the default, and its own privacy. Post Valorant clips from 75, never post Just Chatting, and leave the rest at 90: ```bash curl -X PATCH https://app.clipped.so/api/v1/settings \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{ "autoPost": { "on": true, "minScore": 90 }, "categoryRules": [ { "kind": "game", "value": "Valorant", "on": true, "minScore": 75 }, { "kind": "game", "value": "Just Chatting", "on": false, "minScore": 70 } ] }' ``` ## Auto-posting Every clip gets a score from 0 to 100 for how good a moment it is. A clip posts itself when the rule in force for it is on and the clip reaches the rule's minScore — 70 posts the clearly good ones, 85 and up only the standouts. Which rule is in force, first match wins: the streamer's own rule; then a category rule for the clip's game; then one for its genre; then the account's autoPost. So a streamer switched off stays off even with everything else on, and a game rule can lift or silence one game without touching the rest. Clips that post themselves go to YouTube, to the account the routing rules pick (or the default account), at most postsPerDay a day. TikTok and Instagram are only posted to when asked: a TikTok post lands in the creator's inbox to finish in the app, and an Instagram Reel goes up publicly the moment it is sent. ## Clips A clip is a moment Clipped found. Ask for them with a cursor: the answer carries a new one, and passing it back gives you only what has happened since. A cursor never skips a clip, which is the thing an offset cannot promise while clips are being written; a clip comes back again when it changes, such as when it is cut or posted. A clip that changes — it is posted, or given a better title — moves to the end of the feed again, so a cursor sees that as well. Each clip has a video URL. Fetch it with the same key — it is an mp4 and supports range requests, so you can pull part of one without the whole file. Most clips are cut only when somebody asks for one, so cut says whether the mp4 exists yet: fetching the video of an uncut clip starts the cut and answers 202 with Retry-After until it is ready, usually within a minute. Clips that post themselves are cut straight away. Each clip also has a thumbnail: a still from the middle of it, a small WebP at a public address you can put straight in a page, with no key. It is the same field in the feed, in a webhook's clip and in a live feed's moment. It is null until the still is made — a moment after a clip is cut, a few minutes after an uncut one is found, once the VOD has that part of the stream — and the clip comes back in the feed when it arrives. Not waiting for that: GET /api/v1/clips/{id}/thumbnail (with your key) makes it if it can and redirects to it, so it is the address to use when thumbnail is null, and it follows the clip to its new still if it is cut again. ### A clip - id (string): :. Opaque; URL-encode it in a path. - channel (string): Whose stream it is from. displayName is how they write it. - game (string | null): What they were playing. - title (string | null): The written title, once there is one. - summary (string | null): A line about what happens. - category (string | null): What kind of moment: funny, clutch, rage, … - score (number): 0–100: how good a moment it is. - startedAt (number): When it happened, in wall-clock ms. - seconds (number | null): How long the clip is. - cut (string): ready, cutting, or uncut — cut when video is fetched. - video (string): Where the mp4 is. - thumbnail (string | null): A still from the middle of it: a small WebP (480px wide) at a public URL, no key needed. null until it is made — a moment after a clip is cut, a few minutes after an uncut one is found — and the clip comes back in the feed when it is. GET /api/v1/clips/{id} and …/thumbnail make it there and then. - published (object | null): By destination: { status, url }. status is published, drafted (waiting in TikTok), uploading or failed. - at (string): When it last changed, ISO 8601. The polling loop: ```bash # First call: what is there, and a cursor. curl "https://app.clipped.so/api/v1/clips?limit=5" -H "Authorization: Bearer $KEY" # Every call after: only what is new. curl "https://app.clipped.so/api/v1/clips?cursor=$CURSOR" -H "Authorization: Bearer $KEY" ``` A clip's thumbnail, saved to a file: ```bash # -L follows the redirect to the image. A 202 means it isn't made yet: try again in a minute. curl -L "https://app.clipped.so/api/v1/clips/$CLIP_ID/thumbnail" -H "Authorization: Bearer $KEY" -o thumbnail.webp ``` ## Posting a clip Posting needs the destination's account connected under Settings → Accounts. When more than one is, the routing rules there decide which account a clip goes to, unless account names one. YouTube posts it as a Short, cut vertical if it isn't already; a clip over three minutes is refused, so trim it first. TikTok puts it in the creator's inbox as a draft to caption and post in the app — status comes back drafted, not published. Instagram posts it as a Reel, publicly, straight away. The call waits for the upload, which can take a minute, and answers with where it landed. Leave the title and description out to use the account's templates. ### The body - destination (string): Required. youtube, tiktok or instagram. - account (string): Which connected account, by id (from Settings → destinations). Left out, the routing rules pick. - title (string): This post's title, instead of the template's. - description (string): This post's description. On Instagram, title and description together are the caption. - tags (string[]): YouTube only. - privacy (string): YouTube only: public, unlisted or private. The answer: ```json { "published": { "destination": "youtube", "status": "published", "url": "https://youtube.com/shorts/abc123", "account": "Jynxzi Clips", "accountId": "UC…", "at": 1791918000000 } } ``` ## Updates and webhooks Two ways to hear what happened. Poll: GET /api/v1/clips with a cursor for clips, GET /api/v1/streamers for who is live — the simplest thing, and what an agent that can't receive HTTP does. Or have Clipped POST to a URL of yours as it happens: a webhook. A webhook starts from the moment it is made; nothing from before is sent. It can be limited to some events and some channels. Its URL must be public https, and redirects are not followed. Delivery is at least once, in order. An endpoint that does not answer 2xx within ten seconds is retried, backing off from 15 seconds to an hour, with what came after it held back so order is kept. Twenty failures in a row switch the webhook off; PATCH { "active": true } switches it back on from where it stopped. Each event has an id — if you see one twice, it is the same event. Every request is signed. Clipped-Signature is t=,v1=, where the hex is HMAC-SHA256 of . keyed with the webhook's secret. The secret is in the answer that makes the webhook, and is not shown again. Check the signature against the raw body, before parsing it, and refuse a t more than five minutes old. ### Events - clip.ready (clip): A new clip was found. data is the clip, as GET /api/v1/clips gives it; if its cut is uncut, fetching video cuts it. Its thumbnail is there when it could be made within a few seconds, else null: GET /api/v1/clips/{id}/thumbnail has it later. - clip.published ({ clip, destination, url }): A clip went up somewhere — posted by hand, through the API, or on its own. - streamer.live ({ channel, sessionId, title, game, startedAt }): A streamer you watch went live and is being recorded. - streamer.offline ({ channel, sessionId }): That recording ended. - ping ({ webhook, message }): Only from the test endpoint. What arrives: ```json POST /clipped HTTP/1.1 Content-Type: application/json Clipped-Event: clip.ready Clipped-Delivery: evt_5b1f… Clipped-Signature: t=1791918000,v1=9c2e… { "id": "evt_5b1f…", "type": "clip.ready", "createdAt": "2026-10-13T20:00:00.000Z", "data": { "id": "jynxzi-41234567:m12", "channel": "jynxzi", "title": "Jynxzi clutches a 1v4 on Villa", "score": 88, "seconds": 34.2, "cut": "ready", "video": "https://app.clipped.so/api/v1/clips/jynxzi-41234567%3Am12/video", "thumbnail": "https://cdn.clipped.so/thumbs/jynxzi-41234567/m12-5d0c….webp", "published": null } } ``` Checking the signature (Node): ```javascript import { createHmac, timingSafeEqual } from "node:crypto" export function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))) const t = Number(parts.t) if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex") return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)) } ``` ## Live feeds Everything Clipped reads off a streamer, as it reads it, over a WebSocket. Three per streamer, so a program takes only what it uses: analysis (the signals Clipped scores the stream on, second by second, and the clips it finds), transcript (what the streamer says) and chat (every message). A feed follows a streamer, not one broadcast. Open it whenever you like and keep it open: while they are offline it sends waiting; when a recording starts it sends started, then a backlog of the recent past marked "backlog": true, then everything as it happens; when the recording stops it sends ended and waits for the next. Clipped records the streamers you follow while they are live, so follow one first (POST /api/v1/streamers) — a feed for anyone else is refused. A feed only reads along. It never starts a recording and never keeps one running, so a program left connected costs nothing while the streamer is offline. Send the key as Authorization: Bearer when opening the socket. A browser can't set headers on a WebSocket, so there ?key= works too — only put a key in a page you trust with it. Opening a feed counts as one call against your rate limit, however long it stays open; an account can have 60 open at once. A refused feed is answered before the upgrade, with the usual status and error body. A revoked key, a suspended account or an unfollowed streamer closes an open one within five minutes, with close code 1008. A client that stops reading is disconnected rather than buffered for. Reconnect and you get a fresh backlog. Every message is one JSON object: feed, channel, session (the recording's id, or null while waiting), at (when it was sent, ms), type, and for most types data. Times inside data are wall-clock milliseconds, except a second's t, which is epoch seconds. ### Every feed - waiting (—): No recording of this streamer is running for you: they are offline, or not recording yet. - started (—): A recording started, or was already running when you connected. Its backlog follows. - ended (—): That recording stopped. waiting follows, or started straight away if the streamer is already on their next broadcast. ### analysis - snapshot ({ recording, seconds, moments, words }): The backlog: the recording, its recent seconds, every clip found so far, and the word counts. - recording (recording): The stream's title, game, viewers, whether it is live, and which of chat, audio, transcription and scoring are working. Sent when any of that changes. - seconds (second[]): One per second: messages, chatters, emotes, sentiment (sentSum / sentN), messages per emotion, loudness and peak (dBFS), pitch, brightness, words spoken, the streamer's excitement, and hype, the 0–100 score clips are found on, with the chat and loudness z-scores behind it. A second can be sent again as late scores land: keep the last one for each t. - audio ({ t, db, peak, pitch, centroid, bands }): The audio meters, about ten times a second. - words ({ window, recent, session, streamer }): What chat keeps saying — words, emotes, repeated phrases, and what is trending — over the last minute and the whole recording, and what the streamer keeps saying. - sentiment ({ id, t, sentiment, emotion, confidence }[]): Chat messages scored: sentiment −1 to 1, and an emotion (hype, funny, shock, love, sad, anger, neutral). - moment (moment): A clip found, or one that changed: reviewed, titled, rescored. Its id is the one /api/v1/clips/{id} takes. Includes its score, the triggers that fired, its title and summary, the words and emotes around it, its transcript, a link into the VOD, and its thumbnail: the image's address, or null until it is made, when the moment is sent again with it. ### transcript - transcript (segment[]): { id, start, end, text, language, words: [{ text, start, end }], excitement, tone }. The backlog is the last 150 segments. ### chat - chat (message[]): { id, t, user, name, color, badges, text, emotes, kind, bits, platform }, where kind is chat, sub, raid, bits or announcement and emotes gives each emote's place in text and its image. The backlog is the last 200 messages. - sentiment ({ id, t, sentiment, emotion, confidence }[]): Scores for messages already sent, by their id. Every second of a streamer's signals (Node, with the ws package): ```javascript import WebSocket from "ws" const feed = new WebSocket("wss://app.clipped.so/api/v1/streams/jynxzi/analysis?audio=false", { headers: { Authorization: "Bearer clip_sk_…" }, }) feed.on("unexpected-response", (req, res) => console.error("refused:", res.statusCode)) feed.on("message", (raw) => { const m = JSON.parse(raw) if (m.type === "seconds" && !m.backlog) for (const s of m.data) console.log(s.t, "hype", s.hype, "chat", s.msgs) if (m.type === "moment") console.log("clip:", m.data.id, m.data.score, m.data.title) }) feed.on("close", () => setTimeout(reconnect, 5000)) // and open it again ``` Chat in a browser: ```javascript const chat = new WebSocket("wss://app.clipped.so/api/v1/streams/jynxzi/chat?key=clip_sk_…") chat.onmessage = (e) => { const m = JSON.parse(e.data) if (m.type === "chat") for (const msg of m.data) console.log(msg.name + ":", msg.text) } ``` What arrives on the transcript feed: ```json { "feed": "transcript", "channel": "jynxzi", "session": null, "at": 1791918000000, "type": "waiting" } { "feed": "transcript", "channel": "jynxzi", "session": "jynxzi-41234567", "at": 1791921600000, "type": "started" } { "feed": "transcript", "channel": "jynxzi", "session": "jynxzi-41234567", "at": 1791921600000, "type": "transcript", "backlog": true, "data": [] } { "feed": "transcript", "channel": "jynxzi", "session": "jynxzi-41234567", "at": 1791921612400, "type": "transcript", "data": [{ "id": "s1791921605120", "start": 1791921605120, "end": 1791921609880, "text": "No way. No way he just did that.", "language": "en", "words": [{ "text": "No", "start": 1791921605120, "end": 1791921605300 }, …] }] } ``` ## Analytics How your posted clips are doing on every site they went to, summed per streamer, per site, and by kind of clip, game, length and time of day. Counts are read about hourly with the accounts the clips were posted to: views, likes and comments from YouTube and Instagram, views from Twitch. TikTok shares none. Posts are compared by their views at a day old, and on a log scale, so one clip that took off doesn't stand in for the rest. typicalViews is that typical clip. Every Monday each streamer gets a reflection: what is working and what isn't over the last four weeks, a suggested auto-post score when the numbers support one, and the titles that did best, which new titles for that streamer are written from. The last week, for one streamer: ```bash curl "https://app.clipped.so/api/v1/analytics?days=7&channel=jynxzi" \ -H "Authorization: Bearer clip_sk_…" ``` ## Rate limits Every answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, so a client can pace itself rather than discovering the ceiling by hitting it. The limits are per account, not per key, and are on /api/v1/me. Over the limit is 429 with Retry-After in seconds. Refused calls count against the limit as well, so retrying immediately makes it worse — wait for the reset. Webhook deliveries don't count. ## Errors Every failure is JSON of the same shape: { "error": { "code": "…", "message": "…" } }. The code is stable and worth branching on; the message is for a person reading a log, and says what to do. A thing belonging to another account answers 404, not 403: whether an id exists is not something to confirm to whoever asks. ### Codes - no_key · bad_key (401): No key sent, or one that isn't valid (it may have been revoked). - plan_required (402): The plan doesn't include this — watching streamers needs a paid one. - rate_limited (429): Over the rate limit. Wait Retry-After seconds. - bad_json (400): The body isn't JSON. - no_channel · bad_channel (400): No channel given, or one that isn't a channel. - bad_setting (400): A setting or streamer rule that can't be taken; the message says which. - bad_webhook (400): A webhook URL that isn't public https, or events that don't exist. - bad_id · bad_cursor · bad_limit (400): A clip id, cursor or limit that isn't one of ours. - bad_destination (400): Not somewhere clips can be posted. - no_clip · no_file · not_found (404): No such clip, file or webhook — or not yours. - not_following (403): A live feed for a streamer you don't follow. Follow them first. - no_feed (404): A live feed that isn't analysis, transcript or chat. - too_many_feeds (429): 60 live feeds open on this account already. Close one first. - cutting (409): Posting a clip that is not cut yet. The cut has started; post it again in about a minute. - publish_failed (409): The destination refused, or nothing is connected there. The message is theirs. - not_configured (503): Posting to that destination isn't set up on this server. - internal (500): Our fault. Nothing about it is in the message, on purpose. ## MCP Clipped is an MCP server, so an agent can do everything in these docs without anyone writing HTTP calls: watch streamers, change settings and each streamer's rules, read clips, post them, and set up webhooks. One endpoint, the same key, the same limits. Discover streams with list_games and list_live_streams — for example { "group": "shooters", "limit": 25 }. Results are ranked by combined viewers across the available platforms and include snapshot time when available and any missing platforms. Use a returned channel key with follow_streamer and its optional autoPost rule, or change rules with update_streamer and account defaults with update_settings. Use open_streamer_editor for a standalone crop and settings page with following and auto-post controls. Editor links contain no API key or sign-in token: the browser must sign in to the same Clipped account as the MCP key. Every read and save checks that account again. Opening a link does not follow a streamer or turn posting on. A refusal an agent can act on — no connected account, plan too small, a setting out of range — comes back as a tool result it can read, not a protocol error it can only give up on. Point your agent at it: ```json { "mcpServers": { "clipped": { "type": "http", "url": "https://app.clipped.so/api/mcp", "headers": { "Authorization": "Bearer clip_sk_…" } } } } ``` ### Tools - list_games: Browse Clipped's live game categories and genre groups across Twitch, Kick and YouTube. Returns canonical slugs, viewer counts, snapshot time when available, and any missing platforms. Use group slugs such as shooters with list_live_streams; use a category slug for one game. - list_live_streams: Find the busiest live streams across Twitch, Kick and YouTube, ranked by combined viewers. Optionally filter by a genre group (shooters) or a canonical game category slug from list_games. Default 25, maximum 100. This is the app's directory snapshot: report asOf and missing platforms when describing freshness or coverage. Each result has a channel key for follow_streamer/update_streamer and a private editorUrl for crop, settings, following and auto-post setup. Discovery does not follow anyone or change settings. - open_streamer_editor: Get a standalone browser page to adjust a streamer's live crop, facecam, clip settings, following and automatic posting. Returns a link to open or show the user. It requires a normal Clipped sign-in as the same account this API key belongs to; the URL grants no access and contains no API key or sign-in token. Opening it changes nothing. Works before following or recording a streamer, including offline (live crop preview requires a live stream). - whoami: The Clipped account this key belongs to: its plan, how many channels it may record at once, its stream-hour allowance and how much is left, and the API rate limits. Call this once at the start to find out what the account can do. - list_streamers: The streamers this account is watching, each with `live` (the recording running for them right now, or null) and the auto-post rule, clip ending and taste they are on. A watched streamer keeps being recorded whenever they go live, with nobody at a browser — this is what produces clips. Call it to see who is live. - get_streamer: One streamer: whether you watch them, whether they are live and being recorded now, and the rules their clips are on — each marked in `own` as the streamer's own or the account's default. - update_streamer: Give one streamer rules of their own, which beat the account's settings for their clips: whether their clips post themselves and from what score, whether those get captions burnt in, how long their clips run, and what makes a moment on their stream. Pass null for any of them to put the streamer back on the account's setting; leave one out to leave it alone. Works for a streamer you don't watch yet. - follow_streamer: Start watching a streamer, so Clipped records them whenever they go live and cuts clips from what happens. Takes a name or a link on Twitch, Kick or YouTube — 'jynxzi', 'kick:xqc', or a full URL. Optionally sets whether their clips post themselves in the same call. Needs a paid plan; a free account can only open one channel from the web app. - unfollow_streamer: Stop watching a streamer. Recording stops now; clips already cut are kept. - get_settings: Everything the account has chosen: whether clips post themselves by default and from what score, per-game and per-genre auto-post rules, how many may post a day, upload privacy, the shape clips are cut in, captions, how clips end, what makes a moment (taste), title and description templates, tags — and which destinations have accounts connected. Read this before changing anything. - update_settings: Change the account's settings. Send only what should change; everything left out stays as it is. The whole change is checked first, so one bad field changes nothing. These are the defaults for every streamer — a streamer's own rules (update_streamer) beat them. Answers with every setting as it now is. Connecting a YouTube, TikTok or Instagram account can't be done from here; that is a sign-in the account owner does in the browser. - list_webhooks: The URLs Clipped POSTs to when something happens — a clip is ready, a clip is posted, a streamer goes live or offline — with each one's last delivery and whether it is still on. An agent that can't receive HTTP should poll list_clips and list_streamers instead. - create_webhook: Have Clipped POST events to a public https URL as they happen: clip.ready, clip.published, streamer.live, streamer.offline. Starts from now — nothing from before is sent. The answer has a `secret`, shown only this once: the receiver checks each request's Clipped-Signature header (t=,v1=.">) with it. Up to five per account. - test_webhook: Send a `ping` event to a webhook now and say how its URL answered — to check the receiver and its signature check before anything real happens. - delete_webhook: Stop sending events to a webhook, and forget it. - list_clips: Clips Clipped found, oldest first. This is how you find out a new clip exists: call it with no cursor to see what is there, keep the `cursor` from the answer, and pass it back next time to get only what has happened since. Polling with the cursor never skips a clip; one comes back again when it changes (cut, posted). Each clip has a `video` URL you can fetch with the same API key. Most clips are cut only when asked for: `cut` is `uncut` until then, and fetching `video` starts the cut and answers 202 until the mp4 is ready, about a minute. - get_clip: One clip by id, including its `video` URL and its `thumbnail`, a public image URL (null if it can't be made yet). Fetch that URL with the same API key to watch it; it is an mp4 and supports range requests, so you can pull part of it. If `cut` is not `ready`, fetching it starts the cut and answers 202 with Retry-After until it is, about a minute. - publish_clip: Post a clip to a connected account. The destination must already be connected in Clipped's settings by the person who owns the account — this cannot connect one. YouTube posts it as a Short (three minutes at most); TikTok puts it in the creator's inbox as a draft for them to finish (status `drafted`); Instagram posts it as a public Reel at once. Leave the title and description out to use the account's own templates, which is usually what you want. Waits for the upload and answers with where it landed. # Use cases ## Post a streamer's best clips automatically Watch a streamer, set a score to post from, and let Clipped put the standouts on YouTube with nobody at a keyboard. The setup most accounts want: pick who to watch, say how good a clip has to be, and stop thinking about it. Clipped records the streamer whenever they go live, scores every moment from 0 to 100, cuts the ones that clear your bar and posts them. One thing can't be done with a key: connecting the YouTube account. Its owner signs in once, in the browser, under Settings → Accounts in the app. Everything after that is below. ### 1. Watch the streamer From this call on they are recorded every time they go live. A bare name is Twitch; Kick and YouTube are kick:xqc and youtube:@handle, or a pasted link. ```bash curl -X POST https://app.clipped.so/api/v1/streamers -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "channel": "jynxzi" }' ``` ### 2. Say what is good enough to post A rule is on or off and has a minScore: 70 posts the clearly good moments, 85 and up only the standouts. Set it on the streamer, and it beats the account's default for their clips. ```bash curl -X PATCH https://app.clipped.so/api/v1/streamers/jynxzi -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "autoPost": { "on": true, "minScore": 80 } }' ``` ### 3. Choose the shape, and a daily limit blur-fill is the vertical shape that works for any stream; captions burns them in; postsPerDay is the most that go up in a day, so a wild stream doesn't flood the channel. ```bash curl -X PATCH https://app.clipped.so/api/v1/settings -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "format": "blur-fill", "captions": true, "postsPerDay": 6 }' ``` ### 4. Check an account is connected destinations in the settings says where clips can go and which account is the default. If YouTube isn't connected, nothing posts. ```bash curl https://app.clipped.so/api/v1/settings -H "Authorization: Bearer $KEY" ``` - Clips that post themselves go to YouTube only. TikTok and Instagram are posted to when you ask, one clip at a time. - A rule for one game or genre (categoryRules) sits between the streamer's rule and the account's: use it to post Valorant from 75 and never post Just Chatting. - Watching needs a paid plan; without one the first call answers 402 plan_required. ## Show new clips in your own app Poll the clip feed with a cursor, keep every clip with its thumbnail, and play the video through your own server. For a site, a Discord bot or an internal tool that lists clips as they are found. The feed is built for polling: every answer carries a cursor, and passing it back returns only what happened since, so nothing is seen twice and nothing is missed. ### 1. Poll with a cursor The first call, with no cursor, returns the newest few and a cursor to continue from. Keep the cursor somewhere that survives a restart. A clip comes back again when it changes: when it is cut, titled, posted, or when its thumbnail is made. So store clips by id and overwrite. ```javascript const API = "https://app.clipped.so/api/v1" const headers = { Authorization: `Bearer ${process.env.CLIPPED_KEY}` } let cursor = await loadCursor() // wherever you keep it; undefined the first time while (true) { const query = new URLSearchParams({ limit: "100" }) if (cursor) query.set("cursor", cursor) const page = await (await fetch(`${API}/clips?${query}`, { headers })).json() for (const clip of page.clips) await saveClip(clip) // by clip.id: the same clip returns when it changes cursor = page.cursor await saveCursor(cursor) await new Promise((r) => setTimeout(r, 10_000)) } ``` ### 2. Show the thumbnail thumbnail is a public image address: put it straight in an , no key needed. It is null until the still is made, a moment after a clip is cut or a few minutes after an uncut one is found, and the clip comes back in the feed when it arrives. To have one sooner, ask for it from your server: GET /api/v1/clips/{id}/thumbnail makes it if it can and redirects to the image, or answers 202 with Retry-After when the footage isn't there yet. ```bash curl -L "https://app.clipped.so/api/v1/clips/$CLIP_ID/thumbnail" -H "Authorization: Bearer $KEY" -o thumbnail.webp ``` ### 3. Play the video video needs your key, so a browser can't load it directly: fetch it from your server and pass the bytes on. It is an mp4 and supports Range, so a player can seek. Most clips are cut only when somebody asks. If cut is uncut, the first fetch of video starts the cut and answers 202 with Retry-After; ask again and the mp4 is there, usually within a minute. ```javascript export async function GET(request, { params }) { const upstream = await fetch(`https://app.clipped.so/api/v1/clips/${encodeURIComponent(params.id)}/video`, { headers: { Authorization: `Bearer ${process.env.CLIPPED_KEY}`, Range: request.headers.get("range") ?? "" }, }) // 202: still being cut. Tell your player to try again shortly. return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }) } ``` - A clip id contains a colon. URL-encode it in a path. - ?channel= narrows the feed to one streamer. - Polling every ten seconds is 8,640 calls a day. The limits for your plan are on GET /api/v1/me; a webhook costs none. ## Be told the moment a clip is ready Have Clipped POST to your server when a clip is found, when one is posted, and when a streamer goes live or offline. Instead of asking every few seconds, give Clipped a URL and it calls you. Events arrive in order, at least once, each signed with a secret only you and Clipped hold. ### 1. Make the webhook The URL must be public https. events and channels narrow what is sent; leave them out for everything. The answer carries the webhook's secret, once: keep it. A webhook starts from the moment it is made. Nothing from before is sent. ```bash curl -X POST https://app.clipped.so/api/v1/webhooks -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/clipped", "events": ["clip.ready", "clip.published"] }' ``` ### 2. Check the signature, then answer fast Clipped-Signature is t=,v1=: HMAC-SHA256 of . with the secret. Check it against the raw body, before parsing, and refuse a t more than five minutes old. Answer 2xx within ten seconds and do the work afterwards. Anything else is retried, from 15 seconds up to an hour apart, and later events wait behind it so order is kept. ```javascript import { createHmac, timingSafeEqual } from "node:crypto" import { createServer } from "node:http" function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))) const t = Number(parts.t) if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex") return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)) } createServer(async (req, res) => { let raw = "" for await (const chunk of req) raw += chunk if (!verify(raw, req.headers["clipped-signature"] ?? "", process.env.CLIPPED_WEBHOOK_SECRET)) { res.writeHead(401).end() return } res.writeHead(204).end() // answer first const event = JSON.parse(raw) if (event.type === "clip.ready") console.log("new clip", event.data.id, event.data.score, event.data.thumbnail) if (event.type === "clip.published") console.log("posted", event.data.clip.id, "to", event.data.destination, event.data.url) }).listen(8080) ``` ### 3. Send yourself a test A ping to the webhook now, and the answer says how your URL responded. GET /api/v1/webhooks shows each webhook's last delivery, which is where to look when events stop. ```bash curl -X POST https://app.clipped.so/api/v1/webhooks/$WEBHOOK_ID/test -H "Authorization: Bearer $KEY" ``` - clip.ready is sent once, when the clip is found, which is often before its thumbnail exists. Clipped tries to make one first; when it can't in a few seconds thumbnail is null, and GET /api/v1/clips/{id}/thumbnail has it later. - Every event has an id. Seeing one twice means a retry of the same event: ignore the second. - Twenty failures in a row switch a webhook off. PATCH { "active": true } switches it back on from where it stopped. - An account can have five webhooks. ## Pick the clips yourself, then post them List what was found, look at the ones worth looking at, and post the keepers to YouTube, TikTok or Instagram with your own titles. For when a person, or your own model, decides what goes up. Leave auto-posting off, read the feed, and call publish for the clips you choose. ### 1. Read what was found Every clip has a score, a title and summary once it has been described, its category and its game. Filter on your side: the feed returns every moment that scored as a clip, not only the best. ```bash curl "https://app.clipped.so/api/v1/clips?channel=jynxzi&limit=50" -H "Authorization: Bearer $KEY" ``` ### 2. Watch one Fetch video with your key. An uncut clip is cut on the first fetch, which answers 202 with Retry-After until the mp4 exists. ```bash curl -L "https://app.clipped.so/api/v1/clips/$CLIP_ID/video" -H "Authorization: Bearer $KEY" -o clip.mp4 ``` ### 3. Post it Name the destination. Leave title and description out to use the account's templates, or send your own. The call waits for the upload, which can take a minute, and answers with where it landed. YouTube posts a Short. TikTok puts a draft in the creator's inbox to finish in the app, so its status is drafted. Instagram posts a Reel, publicly, straight away. ```bash curl -X POST "https://app.clipped.so/api/v1/clips/$CLIP_ID/publish" -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{ "destination": "youtube", "title": "He did NOT see that coming", "privacy": "public" }' ``` - Posting a clip that isn't cut yet answers 409 cutting and starts the cut. Post it again in about a minute. - 409 publish_failed carries the destination's own reason: nothing connected, a clip over three minutes on YouTube, a quota. - With several accounts connected, account picks one by id (from destinations in the settings). Left out, your routing rules pick. ## React to a stream while it is live Follow a streamer's hype score, transcript and chat second by second over a WebSocket, and hear about clips the instant they are found. Everything Clipped reads off a stream is available as it is read: the signals it scores on, what the streamer says, and every chat message. Three feeds per streamer, so a program takes only what it uses. A feed follows a streamer, not one broadcast. Open it once and leave it open: it says waiting while they are offline and picks up by itself when they go live. ### 1. Follow the streamer first Feeds exist for streamers on your lineup; one for anyone else is refused with 403 not_following. GET /api/v1/streams lists who you follow with each one's three feed addresses. ```bash curl https://app.clipped.so/api/v1/streams -H "Authorization: Bearer $KEY" ``` ### 2. Open a feed Send the key as a bearer header. Each message is one JSON object with a type; the first after started is a backlog of the recent past, marked "backlog": true. On analysis, seconds carries hype, the 0–100 score clips are found on, and moment is a clip found or changed, with its thumbnail once that is made. ```javascript import WebSocket from "ws" function open() { const feed = new WebSocket("wss://app.clipped.so/api/v1/streams/jynxzi/analysis?audio=false", { headers: { Authorization: `Bearer ${process.env.CLIPPED_KEY}` }, }) feed.on("message", (raw) => { const m = JSON.parse(raw) if (m.type === "seconds" && !m.backlog) for (const s of m.data) if (s.hype >= 85) console.log("hype!", s.t, s.hype) if (m.type === "moment") console.log("clip:", m.data.id, m.data.score, m.data.title, m.data.thumbnail) }) feed.on("close", () => setTimeout(open, 5000)) // reconnect: you get a fresh backlog } open() ``` ### 3. In a browser A browser can't set headers on a WebSocket, so ?key= works too. Only put a key in a page you trust with it: it acts as you. ```javascript const chat = new WebSocket("wss://app.clipped.so/api/v1/streams/jynxzi/chat?key=clip_sk_…") chat.onmessage = (e) => { const m = JSON.parse(e.data) if (m.type === "chat") for (const msg of m.data) console.log(msg.name + ":", msg.text) } ``` - A feed only reads along. It never starts a recording or keeps one running. - Opening a feed counts as one call, however long it stays open. An account can have 60 open at once. - A second can be sent again as late scores land: keep the last one for each t. - A client that stops reading is disconnected rather than buffered for. ## Hand it to an AI agent Connect Claude, or any MCP client, and ask in plain words: find streams, follow streamers, set rules, read clips and post them. Clipped is an MCP server. An agent given the address and a key can do everything these docs describe, with the same limits, and without anyone writing an HTTP call. ### 1. Point the agent at it One endpoint and the same key. Make a key under API & agents in the app. ```json { "mcpServers": { "clipped": { "type": "http", "url": "https://app.clipped.so/api/mcp", "headers": { "Authorization": "Bearer clip_sk_…" } } } } ``` ### 2. Ask for what you want The tools describe themselves, so the agent works out the calls. Things that work well: “Find the five biggest shooter streams live right now and follow the top two, auto-posting anything over 85.” “What did Jynxzi's stream produce today? Show me the three best clips and post the funniest to YouTube as unlisted.” “Stop posting Just Chatting clips, and cap everything at four posts a day.” ### 3. Know what it can't do Connecting a YouTube, TikTok or Instagram account is a sign-in a person does in the browser. Until one is connected, posting is refused, and the refusal comes back as a result the agent can read and explain rather than a protocol error. - The full list of tools is on the MCP page. - Agents that read documentation can take all of this site as one file: /llms.txt. ## See which clips are working Read views, likes and comments for everything you posted, per streamer, site, game and kind of clip, and feed it back into your rules. Clipped reads the counts for your posted clips about hourly, from the accounts they were posted to, and sums them every way that is useful for deciding what to post next. ### 1. Ask for a report days is how far back, 1 to 365 (28 by default); channel narrows it to one streamer. The answer is { report, reflections }. Posts are compared by their views at a day old, on a log scale, so one clip that took off doesn't stand in for the rest. typicalViews is that typical clip. ```bash curl "https://app.clipped.so/api/v1/analytics?days=7&channel=jynxzi" -H "Authorization: Bearer $KEY" ``` ### 2. Act on the reflections Every Monday each streamer gets a reflection: what is and isn't working over the last four weeks, and a suggested auto-post score when the numbers support one. Applying it is one PATCH. ```bash curl -X PATCH https://app.clipped.so/api/v1/streamers/jynxzi -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{ "autoPost": { "on": true, "minScore": 78 } }' ``` - Views, likes and comments come from YouTube and Instagram, views from Twitch. TikTok shares none.