Monitors & webhooks
Watch a YouTube channel or a TikTok or Instagram profile, and receive each new video as a signed webhook, optionally with its transcript.
A monitor watches a YouTube channel or a TikTok or Instagram profile, and tells you about every video that appears after you create it. Each check that finds new videos records one event, POSTs it to your webhook with a signature, and keeps it readable from the events endpoint; with transcripts: true the event carries each new video's transcript as well. It is the push version of polling a channel with since_video_id.
What a monitor can watch
| Target | Platforms |
|---|---|
A YouTube @handle, UC… id or channel URL | YouTube |
| A profile URL | TikTok, Instagram |
The platform is read from the target, so there is nothing else to choose. type is optional: channel is the only type and the default.
On YouTube a monitor can be narrower: tab (videos, shorts or live) watches the channel's uploads, which is the default, its Shorts or its live streams; a creator who posts only Shorts needs shorts. sort and query are not available on monitors. The monitor lists what it was given under options.
A monitor's target and tab are fixed; create another monitor to watch something else. Pinned posts and reordered tabs keep surfacing old videos, so a monitor passes over anything published more than 24 hours before it was created.
Playlists and searches are not watched. Monitors cover channels and profiles only. Read a playlist or run a search on demand with the playlist and search endpoints; a playlist or search monitor is refused with 400 invalid_request.
Create a monitor
curl https://transcriptfetch.com/api/v2/monitors \ -H "Authorization: Bearer $TRANSCRIPTFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"target":"@lexfridman","webhook_url":"https://example.com/hooks/transcriptfetch","interval_minutes":60,"transcripts":true,"name":"Lex Fridman uploads"}'
Creating a monitor lists its target once, for 1 credit, like any check. A channel or profile that does not exist is refused with 404 not_found; otherwise its first page today is recorded as already seen (baseline_count says how many), so you only hear about videos that appear from now on. The monitor is then checked every interval_minutes: 15, 60, 360 or 1440, default 60. webhook_url, interval_minutes, transcripts and name can all be changed later.
{ "ok": true, "request_id": "req_…", "data": { "kind": "monitor", "id": "mon_m3k1x9qz4vb2p7", "type": "channel", "platform": "youtube", "target": "@lexfridman", "options": { "tab": "videos" }, "name": "Lex Fridman uploads", "status": "active", "has_new": true, "last_event_id": "mev_m3k1xa0b7c8d9e", "interval_minutes": 60, "transcripts": true, "webhook_url": "https://example.com/hooks/transcriptfetch", "next_check_at": "2026-09-25T15:00:12.000Z", "last_checked_at": null, "last_error": null, "created_at": "2026-09-24T09:12:40.000Z", "updated_at": "2026-09-24T09:12:40.000Z", "webhook_secret": "whsec_…", "baseline_count": 30 }, "usage": { "credits_spent": 0, "balance": 250 } }
Store webhook_secret now. The 201 response is the only place it appears, and every delivery is signed with it. There is no way to read it again: if you lose it, delete the monitor and create a new one.
How checks run
- One check reports at most 50 new videos. The rest wait for the next checks; none are dropped.
- Scheduled checks run one per account at a time, so an account's monitors take turns.
- A live stream has no transcript until it ends. With
transcripts: true, an upcoming or ongoing broadcast (alivetab monitor finds them early) is looked for every 30 minutes for up to 7 days, and the caption wait starts when the stream ends.
Receive the webhook
Each check that finds new videos sends one POST to webhook_url, with the event as its JSON body:
{ "id": "mev_m3k1xa0b7c8d9e", "type": "monitor.videos", "created_at": "2026-09-25T14:00:09.000Z", "monitor": { "id": "mon_m3k1x9qz4vb2p7", "name": "Lex Fridman uploads", "type": "channel", "platform": "youtube", "target": "@lexfridman" }, "credits_spent": 2, "data": { "videos": [ { "videoId": "dQw4w9WgXcQ", "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "title": "Example video", "duration": 212, "channel": "Example Channel", "publishedAt": "2026-09-25T13:00:00Z", "stats": { "plays": 1200 } } ], "transcripts": [ { "video_id": "dQw4w9WgXcQ", "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "outcome": "ok", "transcript": { "kind": "transcript", "video_id": "dQw4w9WgXcQ", "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "platform": "youtube", "title": "Example video", "channel": "Example Channel", "duration": 212, "language": "en", "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", "source": "captions", "text": "We're no strangers to love …", "segments": [ { "start": 0, "duration": 3.5, "text": "We're no strangers to love" } ] } } ] } }
type is monitor.videos and data.videos holds the new rows exactly as the listing endpoints return them. With transcripts: true, data.transcripts has one entry per video: ok with the transcript, error with the standard error block, or processing while it is still on its way. A transcript that arrives later is sent as its own monitor.transcript event, whose data.videos_event_id names the event that announced the video.
Every delivery carries these headers:
X-TranscriptFetch-Signature:sha256=and the hex HMAC-SHA256 of the raw body, keyed with the monitor'swebhook_secretX-TranscriptFetch-Event: the event type,monitor.videosormonitor.transcriptX-TranscriptFetch-Delivery: the event id, the same on every retry of that event
Answer with any 2xx within 10 seconds. Anything else (another status, a timeout, a redirect, which is never followed, or an unreachable host) is retried after 1, 5, 30, 120 and 720 minutes, and after 6 attempts the delivery is marked failed. A delivery can arrive more than once, so deduplicate on X-TranscriptFetch-Delivery. Webhooks are optional: every event, delivered or not, stays readable from GET /api/v2/monitors/{monitorId}/events for 30 days, with a delivery block saying where it stands.
Verify the signature
Compute the HMAC over the exact bytes you received, before parsing them, and compare in constant time. The key is the whole secret, whsec_ prefix included.
import { createHmac, timingSafeEqual } from "node:crypto"; // rawBody: the request body exactly as received (a Buffer or string). export function verifySignature(rawBody, signatureHeader, secret) { const expected = Buffer.from( "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"), ); const received = Buffer.from(signatureHeader ?? ""); return received.length === expected.length && timingSafeEqual(received, expected); }
import hashlib import hmac # raw_body: the request body as bytes, before any JSON parsing. def verify_signature(raw_body: bytes, signature_header: str | None, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), (signature_header or "").encode())
Read the raw body the way your framework exposes it: express.raw({ type: "application/json" }) in Express, await req.text() in a Next.js route handler, request.get_data() in Flask, await request.body() in FastAPI. Answer 401 to a delivery whose signature does not match, and parse the JSON only once it does.
Check, pause and delete
POST /api/v2/monitors/{monitorId}/checkruns a check now, billed like a scheduled one, and returns what it found within about 85 seconds. Any event it recorded has had its first webhook attempt, unless time ran short: then that attempt, like any transcript it did not reach, is left to the scheduler. It is the quickest way to test your endpoint.PATCH /api/v2/monitors/{monitorId}changeswebhook_url,interval_minutes,transcriptsorname, or setsstatustopausedoractive. A resumed monitor reports what appeared while it was paused, up to the newest page.DELETE /api/v2/monitors/{monitorId}removes the monitor and its events.GET /api/v2/monitorslists your monitors together with your account's limits.
Know when something is new
No webhook needed: every monitor tells you whether it has found anything since you last looked. has_new is true or false, and last_event_id names the newest event.
- Read
GET /api/v2/monitorsand keep thelast_event_idit returns. - Next time, send it back:
GET /api/v2/monitors?since=mev_…. The top-levelhas_newsays whether anything arrived since, and each monitor'shas_newsays which. - Fetch just the new events with
GET /api/v2/monitors/{monitorId}/events?since=mev_…, then keep the newlast_event_id.
Reading never marks anything as read: you pass back what you last saw, so a dashboard, a script and a retry never take the news from each other. Without since, has_new means the monitor has found something in the last 30 days. An id older than that counts as older than every event, so has_new can only err towards true.
What monitors cost
Every check costs 1 credit, the price of one listing page, whether it finds new videos or nothing, and however many it finds. So does the first read when the monitor is created. A check whose listing fails is free. With transcripts: true, each caption transcript costs 1 credit, and a video without captions goes to AI Fallback Transcription at 1 credit per started 5 minutes of audio (minimum 1), billed when the transcript is delivered. Brand-new YouTube uploads usually get automatic captions some minutes after they go live, so YouTube captions are retried every 15 minutes for up to 60 minutes before AI Fallback Transcription is used.
If the account cannot pay when a check finds videos, nothing is recorded: the videos are not marked seen, the monitor's last_error reads insufficient_credits, and the first check after you top up delivers them. Listing, reading, changing and deleting monitors, and reading their events, are free.
Limits
| Limit | Without a paid plan | On any paid plan |
|---|---|---|
| Monitors per account | 5 | 100 |
| Shortest interval | 1440 minutes | 15 minutes |
Creating a monitor past the cap answers 400 monitor_limit_reached, and an interval shorter than the plan allows is refused with 400 invalid_request. Monitors need an API key from TranscriptFetch itself: they are not available through a marketplace subscription such as RapidAPI, whose billing cannot meter scheduled checks. Every field, response and error is in the monitor endpoints below.
Monitor endpoints
Every call for creating and managing monitors: parameters, request and response, with Try it to send a live call.
/api/v2/monitorsidempotentCreate a monitor for new videos
Watch a YouTube channel or a TikTok or Instagram profile, and receive every new video as an event, by webhook and from the events endpoint. Creation reads the target once, for 1 credit like any check, to learn what is already there.
- Baseline: a channel or profile that does not exist answers 404 not_found. Everything on its first page today is recorded as already seen, baseline_count says how many, so only videos that appear later are reported.
- Each check, every interval_minutes, reads the newest 30 rows and reports the ones it has not seen as one monitor.videos event, sent to webhook_url when one is set and always readable from the events endpoint. One check reports at most 50 videos; any more are reported by the next checks.
- Billing: every check costs 1 credit, whether it finds new videos or nothing, and so does the baseline read when the monitor is created. With transcripts: true, each caption transcript costs 1 more. A find is charged before its event is recorded or delivered; a check whose listing fails is free.
- An account that cannot pay records nothing: the videos are not marked seen, last_error reads insufficient_credits, the monitor stays active, and the same videos are delivered by the first check after credits return.
- Limits: 5 monitors and a 1440-minute minimum interval without a paid plan, 100 monitors and 15 minutes on any paid plan. Creating past the cap answers monitor_limit_reached.
- A monitor does not report a video published more than 24 hours before it was created (pinned posts and reordered tabs keep resurfacing old videos).
- YouTube channels: tab chooses the uploads (default), the Shorts or the live streams; a creator who posts only Shorts needs tab: "shorts". Fixed once created.
- Monitors watch channels and profiles only. A playlist or search type is refused with invalid_request; read those on demand with the playlist and search endpoints.
- The 201 response carries webhook_secret, the key every delivery is signed with. It is shown this once: store it.
Body parameters
type"channel"optionaltargetstringrequiredplatformstring (enum)optionaltab"videos" | "shorts" | "live"optionalwebhook_urlstring (https URL) | nulloptionalinterval_minutes15 | 60 | 360 | 1440optionaltranscriptsbooleanoptionalnamestring (up to 100) | nulloptionalRequest example
Responses
{
"ok": true,
"request_id": "req_…",
"data": {
"kind": "monitor",
"id": "mon_m3k1x9qz4vb2p7",
"type": "channel",
"platform": "youtube",
"target": "@lexfridman",
"options": {
"tab": "videos"
},
"name": "Lex Fridman uploads",
"status": "active",
"has_new": true,
"last_event_id": "mev_m3k1xa0b7c8d9e",
"interval_minutes": 60,
"transcripts": true,
"webhook_url": "https://example.com/hooks/transcriptfetch",
"next_check_at": "2026-09-25T15:00:12.000Z",
"last_checked_at": null,
"last_error": null,
"created_at": "2026-09-24T09:12:40.000Z",
"updated_at": "2026-09-24T09:12:40.000Z",
"webhook_secret": "whsec_…",
"baseline_count": 30
},
"usage": {
"credits_spent": 0,
"balance": 250
}
}/api/v2/monitorsList your monitors
Every monitor on the account, newest first, with the account's monitor limits (max_monitors is null when uncapped). Free. A monitor's webhook_secret is never returned here.
- has_new at the top is true when any monitor has found something after since (without since: when any has found anything in the retention window); each monitor's has_new says which. last_event_id at the top is the account's newest event: pass it back as since next time to ask, in one call, whether anything is new.
Query parameters
sincestring (event id)optionalRequest example
Responses
{
"ok": true,
"request_id": "req_…",
"data": {
"kind": "monitor_list",
"has_new": true,
"last_event_id": "mev_m3k1xa0b7c8d9e",
"monitors": [
{
"kind": "monitor",
"id": "mon_m3k1x9qz4vb2p7",
"type": "channel",
"platform": "youtube",
"target": "@lexfridman",
"options": {
"tab": "videos"
},
"name": "Lex Fridman uploads",
"status": "active",
"has_new": true,
"last_event_id": "mev_m3k1xa0b7c8d9e",
"interval_minutes": 60,
"transcripts": true,
"webhook_url": "https://example.com/hooks/transcriptfetch",
"next_check_at": "2026-09-25T15:00:12.000Z",
"last_checked_at": "2026-09-25T14:00:09.000Z",
"last_error": null,
"created_at": "2026-09-24T09:12:40.000Z",
"updated_at": "2026-09-24T09:12:40.000Z"
}
],
"limits": {
"max_monitors": 100,
"min_interval_minutes": 15
}
}
}/api/v2/monitors/{monitorId}Get a monitor
One monitor: its settings, when it was last checked and is next due (null while paused), last_error, the error block of the last check that failed or could not be paid for (null once a check succeeds), and whether it has found anything new. A monitor that belongs to another account answers 404, like one that does not exist. Free.
- has_new and last_event_id: last_event_id is the monitor's newest event. Keep it, send it back as since next time, and has_new answers whether anything arrived after it. Without since, has_new is true when the monitor has found anything in the retention window. Reading never marks anything as read.
Query parameters
sincestring (event id)optionalRequest example
Responses
{
"ok": true,
"request_id": "req_…",
"data": {
"kind": "monitor",
"id": "mon_m3k1x9qz4vb2p7",
"type": "channel",
"platform": "youtube",
"target": "@lexfridman",
"options": {
"tab": "videos"
},
"name": "Lex Fridman uploads",
"status": "active",
"has_new": true,
"last_event_id": "mev_m3k1xa0b7c8d9e",
"interval_minutes": 60,
"transcripts": true,
"webhook_url": "https://example.com/hooks/transcriptfetch",
"next_check_at": "2026-09-25T15:00:12.000Z",
"last_checked_at": "2026-09-25T14:00:09.000Z",
"last_error": null,
"created_at": "2026-09-24T09:12:40.000Z",
"updated_at": "2026-09-24T09:12:40.000Z"
}
}/api/v2/monitors/{monitorId}Update or pause a monitor
Change a monitor's webhook_url, interval_minutes, transcripts, name or status: send only the fields to change. Free.
- type, target, platform and tab are fixed: create a new monitor for a different target.
- Pausing stops scheduled checks. Resuming checks at the next scheduler tick, from the saved state.
Body parameters
webhook_urlstring (https URL) | nulloptionalinterval_minutes15 | 60 | 360 | 1440optionaltranscriptsbooleanoptionalnamestring (up to 100) | nulloptionalstatus"active" | "paused"optionalRequest example
Responses
{
"ok": true,
"request_id": "req_…",
"data": {
"kind": "monitor",
"id": "mon_m3k1x9qz4vb2p7",
"type": "channel",
"platform": "youtube",
"target": "@lexfridman",
"options": {
"tab": "videos"
},
"name": "Lex Fridman uploads",
"status": "paused",
"has_new": true,
"last_event_id": "mev_m3k1xa0b7c8d9e",
"interval_minutes": 60,
"transcripts": true,
"webhook_url": "https://example.com/hooks/transcriptfetch",
"next_check_at": null,
"last_checked_at": "2026-09-25T14:00:09.000Z",
"last_error": null,
"created_at": "2026-09-24T09:12:40.000Z",
"updated_at": "2026-09-25T14:20:00.000Z"
}
}/api/v2/monitors/{monitorId}Delete a monitor
Delete a monitor, its events and any transcripts it still owed; nothing more is checked or delivered for it. An AI Fallback Transcription already running finishes, is billed as usual, and stays readable from its job. Free.
Request example
Responses
{
"ok": true,
"request_id": "req_…",
"data": {
"kind": "monitor_deleted",
"id": "mon_m3k1x9qz4vb2p7"
}
}/api/v2/monitors/{monitorId}/eventsList a monitor's events
The monitor's events, newest first, with cursor pagination, whether or not it has a webhook. Free.
- monitor.videos carries the new rows exactly as the listing endpoints return them, and with transcripts: true one transcripts entry per video: ok with the transcript, processing, or error with the same error block a request-level failure carries.
- monitor.transcript carries one follow-up transcript entry and videos_event_id, the event that announced the video.
- Each event's delivery block reads none (no webhook), pending (next_attempt_at is the next try), delivered, or failed after 6 attempts.
- Events are kept for 30 days.
- since lists only the events after the one you last saw, newest first; with next_cursor the pages stop at since.
Query parameters
limitinteger (1-50)optionalcursorstringoptionalsincestring (event id)optionalRequest example
Responses
{
"ok": true,
"request_id": "req_…",
"data": {
"kind": "monitor_event_list",
"monitor_id": "mon_m3k1x9qz4vb2p7",
"events": [
{
"id": "mev_m3k1xa0b7c8d9e",
"type": "monitor.videos",
"created_at": "2026-09-25T14:00:09.000Z",
"monitor": {
"id": "mon_m3k1x9qz4vb2p7",
"name": "Lex Fridman uploads",
"type": "channel",
"platform": "youtube",
"target": "@lexfridman"
},
"credits_spent": 2,
"data": {
"videos": [
{
"videoId": "dQw4w9WgXcQ",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"title": "Example video",
"duration": 212,
"channel": "Example Channel",
"publishedAt": "2026-09-25T13:00:00Z",
"stats": {
"plays": 1200
}
}
],
"transcripts": [
{
"video_id": "dQw4w9WgXcQ",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"outcome": "ok",
"transcript": {
"kind": "transcript",
"video_id": "dQw4w9WgXcQ",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"title": "Example video",
"channel": "Example Channel",
"duration": 212,
"language": "en",
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg",
"source": "captions",
"text": "We're no strangers to love …",
"segments": [
{
"start": 0,
"duration": 3.5,
"text": "We're no strangers to love"
}
]
}
}
]
},
"delivery": {
"status": "delivered",
"attempts": 1,
"last_attempt_at": "2026-09-25T14:00:10.000Z",
"next_attempt_at": null,
"last_error": null,
"delivered_at": "2026-09-25T14:00:10.000Z"
}
}
],
"next_cursor": "eyJ0IjoiNDIifQ"
}
}/api/v2/monitors/{monitorId}/checkidempotentCheck a monitor now
Run a check immediately, billed exactly like a scheduled one: 1 credit whether or not it finds videos (plus caption transcripts). The next scheduled check moves to one interval from now.
- When it finds videos, the event's first webhook attempt has been made by the time this returns, so its delivery block shows whether your endpoint accepted it.
- A listing that fails (the channel was deleted, the platform did not answer) is the check's result, not a failed request: 200 with new_videos 0, event null and data.error, the error block the monitor's last_error now shows. Nothing is charged.
- Only a monitor that does not exist answers 404. An account that cannot pay answers 402 insufficient_credits, with nothing recorded.
- A check of this monitor that is already running answers 429 with Retry-After, and so does one the scheduler took over partway: nothing it did was charged or recorded, and the scheduled check reports the videos.
- Caption transcripts the check does not reach in time are not dropped: they follow as monitor.transcript events.
- Works on a paused monitor too.
Request example
Responses
{
"ok": true,
"request_id": "req_…",
"data": {
"kind": "monitor_check",
"monitor": {
"kind": "monitor",
"id": "mon_m3k1x9qz4vb2p7",
"type": "channel",
"platform": "youtube",
"target": "@lexfridman",
"options": {
"tab": "videos"
},
"name": "Lex Fridman uploads",
"status": "active",
"has_new": true,
"last_event_id": "mev_m3k1xa0b7c8d9e",
"interval_minutes": 60,
"transcripts": true,
"webhook_url": "https://example.com/hooks/transcriptfetch",
"next_check_at": "2026-09-25T15:00:21.000Z",
"last_checked_at": "2026-09-25T14:00:09.000Z",
"last_error": null,
"created_at": "2026-09-24T09:12:40.000Z",
"updated_at": "2026-09-24T09:12:40.000Z"
},
"new_videos": 1,
"event": {
"id": "mev_m3k1xa0b7c8d9e",
"type": "monitor.videos",
"created_at": "2026-09-25T14:00:09.000Z",
"monitor": {
"id": "mon_m3k1x9qz4vb2p7",
"name": "Lex Fridman uploads",
"type": "channel",
"platform": "youtube",
"target": "@lexfridman"
},
"credits_spent": 2,
"data": {
"videos": [
{
"videoId": "dQw4w9WgXcQ",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"title": "Example video",
"duration": 212,
"channel": "Example Channel",
"publishedAt": "2026-09-25T13:00:00Z",
"stats": {
"plays": 1200
}
}
],
"transcripts": [
{
"video_id": "dQw4w9WgXcQ",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"outcome": "ok",
"transcript": {
"kind": "transcript",
"video_id": "dQw4w9WgXcQ",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"title": "Example video",
"channel": "Example Channel",
"duration": 212,
"language": "en",
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg",
"source": "captions",
"text": "We're no strangers to love …",
"segments": [
{
"start": 0,
"duration": 3.5,
"text": "We're no strangers to love"
}
]
}
}
]
},
"delivery": {
"status": "delivered",
"attempts": 1,
"last_attempt_at": "2026-09-25T14:00:10.000Z",
"next_attempt_at": null,
"last_error": null,
"delivered_at": "2026-09-25T14:00:10.000Z"
}
}
},
"usage": {
"credits_spent": 2,
"balance": 248
}
}