# FastSaverAPI — full site text Source: https://fastsaverapi.com · Generated 2026-08-10 The API itself runs on https://api.fastsaver.io (moved from fastsaverapi.com on 2026-07-24). --- # FastSaverAPI is now at api.fastsaver.io URL: https://fastsaverapi.com/ Summary: Project home. What FastSaverAPI is, the fastsaverapi.com → api.fastsaver.io domain change, and the index of platform references and guides. Updated: 2026-08-10 One REST API that turns a social-media link into a direct download URL and clean JSON. Nine platforms, one key. Since 2026-07-24 it is served from api.fastsaver.io — this site keeps the references and the guides. - **New base URL**: https://api.fastsaver.io/v1 - **Old base URL**: https://fastsaverapi.com/v1 · 301 - **API keys**: unchanged - **Response shape**: identical Moved 2026-07-24. Only the host changed — the full migration note lists every old path and where it now points. ## one API key, one request shape - Send a link and your X-Api-Key . Get JSON with a direct download URL. - No cookies, no proxies, no headless browser, no watermarks. - Nine platforms behind two endpoint shapes — /fetch and the YouTube family. - Telegram file_id for cached audio, so a bot replies without touching the file. - Pay per request: 1–25 credits depending on the call. Free tier is 1,000 credits, no card — see plans . POST https://api.fastsaver.io/v1/youtube/download the heaviest call there is ``` curl -X POST https://api.fastsaver.io/v1/youtube/download \ -H "X-Api-Key: fs_sk_•••••••••••" \ -H "Content-Type: application/json" \ -d '{"url": "https://youtu.be/6dG1WWjew8s", "format": "2160p"}' ``` ``` { "ok": true, "video_id": "6dG1WWjew8s", "duration": 577, "download_url": "https://api.fastsaver.io/v1/tunnel?id=3f7a1c9e-52b4-4d18" } ``` ## pick your platform Each page is a reference: the exact call, the response fields, the credit cost, working code, and where it stops working. ### Instagram Reels, posts, carousels, stories. ### TikTok Videos and photo posts, no watermark. ### YouTube Up to 4K, audio, search, metadata. ### Telegram bots Cached audio as a ready file_id . ### Shazam Recognise a song, charts, lyrics. ### Pinterest Pins, pin videos, idea pins. ### X (Twitter) Videos and GIFs from public posts. ### Facebook Public videos and reels. ### RuTube Russian-language video hosting. ## or start from a working example ### Telegram music bot Search, then answer with a cached file_id . ### Instagram reels in Python A downloader with retries and streaming. ### YouTube to MP3 Audio over HTTP, without yt-dlp. ### TikTok without the watermark Why the clean file is hard to get. ## Keep reading - The domain change — old paths → new paths - FAQ — credits, limits, legality - API docs & live playground — on api.fastsaver.io - llms.txt — machine-readable index ## Get a key and make the first call 1,000 credits free, no card. Sign-up and the playground are on api.fastsaver.io. Get an API key Read the docs --- # fastsaverapi.com is now api.fastsaver.io URL: https://fastsaverapi.com/moved/ Summary: Official domain-migration notice: fastsaverapi.com → api.fastsaver.io on 2026-07-24. API keys, endpoint paths and response shapes unchanged; full 301 redirect map and migration checklist. Updated: 2026-08-10 The domain changed on 2026-07-24: fastsaverapi.com handed the API over to api.fastsaver.io . Your key still works, every endpoint kept its path and every response kept its shape. The only edit your code needs is the base URL — here is exactly what changed, and where each old address now points. - **Moved on**: 2026-07-24 - **New base URL**: https://api.fastsaver.io/v1 - **Old base URL**: https://fastsaverapi.com/v1 - **Action needed**: change one constant ## the one thing you have to change Replace the host in your base URL. That is the entire migration. ``` - BASE = "https://fastsaverapi.com/v1" + BASE = "https://api.fastsaver.io/v1" ``` Everything downstream of that line is unchanged: the X-Api-Key header, the paths, the query parameters, the JSON bodies and every field in the response. ## what did not change - Your API key. Same key, same account, nothing to regenerate. - Every endpoint path. /fetch , /youtube/download , /shazam/identify and the rest keep their names. - Request and response shapes. Field for field identical. - Credit costs and plans. Same prices, same allowances, same rate limits. - Your balance and history. The dashboard is the same account on a new host. - Support. Still the same person on Telegram. ## where every old URL now points Each of these returns a permanent 301 , so bookmarks, backlinks and old code keep resolving. Search engines follow them and transfer the ranking signals with them. | Old URL on fastsaverapi.com | Now redirects to | | --- | --- | | /docs | api.fastsaver.io/docs | | /pricing | api.fastsaver.io/pricing | | /auth | api.fastsaver.io/auth | | /dashboard | api.fastsaver.io/dashboard | | /topup | api.fastsaver.io/topup | | /transactions | api.fastsaver.io/transactions | | /admin | api.fastsaver.io/admin | | /v1/* | api.fastsaver.io/v1/* | Everything else on this host — the platform references, the guides, this page — stays here and stays indexable. If you land on a path that no longer exists, the 404 page points you at the right place. ## why the domain changed The short answer is architecture. api.fastsaver.io says what the host is, and putting the API on its own subdomain lets each piece be scaled, cached, rate-limited and moved independently — the endpoint, the file transfers it hands back and this content site are three different workloads. A single apex domain made that awkward. It also separates concerns that were tangled together: the API endpoint, the file delivery and this content site are three different workloads with three different traffic shapes, and they no longer share a hostname or a cache policy. None of that should cost you anything, which is why the paths, the payloads and the keys were deliberately left alone. The migration is a one-line change on purpose. ## a five-minute checklist - Update the base URL constant in every service that calls the API. - Grep your codebase for fastsaverapi.com — config files, environment variables, CI secrets, Postman collections and README snippets all tend to hold a copy. - If you allowlist outbound hosts, add api.fastsaver.io — the download links the API returns are served from it too. - Redeploy, then call GET /balance — it is free and confirms the key still authenticates. - Update any documentation or support macros that quote the old URL. ``` curl "https://api.fastsaver.io/v1/balance" -H "X-Api-Key: fs_sk_•••••••••••" ``` ## questions about the move **Q: Do I need a new API key?** A: No. Keys issued on the old domain keep working. Nothing was reissued, revoked or migrated — the same key authenticates against the new host. **Q: Will requests to the old base URL keep working?** A: Old paths are 301-redirected, and a well-behaved HTTP client follows a 301. But redirects add a round trip, and some clients drop custom headers such as X-Api-Key when they follow one — which shows up as a confusing 401. Point your code at https://api.fastsaver.io/v1 and the problem disappears. **Q: Did any endpoint, parameter or response field change?** A: No. Same paths, same methods, same parameters, same JSON. The only edit your code needs is the hostname in one constant. **Q: What happened to my dashboard, balance and plan?** A: Nothing — it is the same account and the same database, served from api.fastsaver.io/dashboard . Your credits, plan and transaction history came across untouched. **Q: Why keep fastsaverapi.com online at all?** A: Because links to it exist all over the internet — in bot source code, in forum answers, in bookmarks. Turning the domain off would break every one of them. It now hosts the references and guides, and points anything operational at the new host. ## Keep reading - What FastSaverAPI does — overview and pricing - Supported platforms — nine references - API documentation — on the new host - Ask on Telegram — if something broke ## Everything else is where you left it Open the docs on the new host and confirm your key works — the playground fills it in for you once you sign in. Open api.fastsaver.io/docs Go to the dashboard --- # every platform the API covers URL: https://fastsaverapi.com/platforms/ Summary: Directory of all nine supported platforms with their endpoints and credit costs, plus an explanation of the two request shapes (universal /fetch versus the YouTube endpoint family). Updated: 2026-08-10 Nine platforms, two request shapes, one API key. Start here to find the reference page for the platform you are integrating — each one covers the exact call, the response fields, what it costs and where it stops working. ## the nine platforms Each page below is a reference, not a brochure: the exact request, the response fields, the credit cost, working code, and the cases where it will not work. ### Instagram GET /fetch · 1.5–5 credits Posts, reels, carousels, stories and highlights. Original quality, caption included. ### TikTok GET /fetch · 1.5 credits Videos and photo slideshows with the watermark already gone — not cropped off. ### YouTube 4 endpoints · 2–25 credits Video from 144p to 4K, audio extraction, YouTube Music search and full format metadata. ### Telegram bots POST /youtube/audio/tg-bot · 7–15 credits A cached file_id your bot forwards instantly, instead of downloading and re-uploading. ### Shazam 3 endpoints · 1–5 credits Identify a song from an audio file, read country charts, fetch lyrics by title and artist. ### Pinterest GET /fetch · 1.5 credits Pins, pin videos and idea pins at their original resolution, not the in-feed resize. ### X (Twitter) GET /fetch · 1.5 credits Videos and GIFs from public posts — no developer account, no OAuth, no monthly tier. ### Facebook GET /fetch · 1.5 credits Public videos and reels across every URL shape Facebook has ever shipped. ### RuTube GET /fetch · 3 credits Russian-language video hosting, reached through the same request as everything else. ## there are really only two request shapes Six of the nine platforms go through a single universal endpoint. You pass a link and the API identifies the platform itself — which means adding a new one to your product is usually a validation change, not an integration. ``` # Instagram, TikTok, Pinterest, X, Facebook, RuTube — all the same call curl "https://api.fastsaver.io/v1/fetch?url=" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` YouTube gets its own family of endpoints because it needs choices the others do not: which resolution, audio or video, search before download, and the Telegram file_id path for bots. ``` # YouTube — pick a format explicitly curl -X POST https://api.fastsaver.io/v1/youtube/download \ -H "X-Api-Key: fs_sk_•••••••••••" \ -H "Content-Type: application/json" \ -d '{"url": "https://youtu.be/6dG1WWjew8s", "format": "audio"}' ``` Shazam is the exception to both: it takes an audio file rather than a link, and answers with a song identity instead of a download. ## what every platform has in common - The same X-Api-Key header. One key for everything. - The same envelope: ok: true and a payload, or ok: false and a reason. - A source field naming the platform, so one worker can handle every link type. - Public content only. Private accounts, follower-gated posts and deleted media return an error, not a file. - Signed download URLs that expire. Fetch the bytes in the same job rather than storing the link. - Per-plan rate limits, from 10 requests per minute on Free to 900 on Mega. When a platform changes its markup or its player — and one of them does, most months — the fix ships behind the endpoint. Your integration does not move. ## Keep reading - Guides — build something with these endpoints - Frequently asked questions — credits, limits, legality - Domain change notice — fastsaverapi.com → api.fastsaver.io - Full API reference — with a live playground ## Start on the free tier 1,000 credits, no card. Grab a key and make your first call in a couple of minutes. Get an API key Read the docs --- # developer guides for the FastSaverAPI endpoints URL: https://fastsaverapi.com/guides/ Summary: Index of the four FastSaverAPI guides, plus the shared setup steps and the complete API error surface — 401, 429 and the 400-with-detail that stands in for a 402 when credits run out. Updated: 2026-08-10 Four code-first guides, each built around something people actually ship. Every one starts from an empty file and ends with something that runs — including the error paths most tutorials leave out, and what each call actually costs. ## the guides Four walkthroughs, each built around something people actually ship. Every code sample runs against the live API — no pseudocode, no ... standing in for the hard part. ### Build a Telegram music bot python · aiogram Search YouTube Music, then answer with a cached file_id — no file ever touches your server. Full aiogram 3 handler. ### Download Instagram reels in Python python · requests From a naive call to a batch runner with streaming, retries and timeouts. Why the CDN URL expires and what that means for a queue. ### YouTube to MP3 over HTTP python · node Extract audio without yt-dlp or ffmpeg on your own box — and an honest account of what you give up by not running them. ### TikTok without the watermark explainer · code Why the clean render is hard to get, why cropping and re-encoding are the wrong answer, and what the API hands back instead. ## what you need before any of them All four guides assume the same two minutes of setup. - An API key. Sign up at api.fastsaver.io — the free tier gives you 1,000 credits and does not ask for a card. - An HTTP client. requests in Python, fetch in Node, curl at a prompt. No SDK exists and none is needed. - The key in an environment variable, not in the source file you are about to commit. ``` export FASTSAVER_KEY="fs_sk_•••••••••••" curl "https://api.fastsaver.io/v1/balance" -H "X-Api-Key: $FASTSAVER_KEY" ``` If that returns "ok": true with your plan and credit balance, every guide here will work as written. ## the four errors you will actually hit Rather than repeat error handling in every guide, here is the whole surface once. | Status | detail | What to do | | --- | --- | --- | | 401 | Invalid API key | The key is missing, mistyped, or was dropped by a client following a redirect. Send X-Api-Key straight to the current base URL. | | 400 | Insufficient credits. Please top up to your account. | Out of credits. Match on the detail string — the status is a plain 400, not a 402. GET /balance is free, so poll that instead of guessing. | | 429 | Rate limit exceeded… | You passed your plan's requests-per-minute limit. Back off and retry; a token bucket beats a retry loop. | | 400 | ok: false with a reason | The link is private, deleted, region-locked or not a supported platform. Surface it to the user — retrying will not help. | Two things surprise people here. There is no 402 — running out of credits answers a plain 400 , so branch on the detail string rather than the status code. And ok , not the status, is the real success flag: read it first and every integration gets simpler. ## follow along New guides are added as endpoints change. There is an RSS feed if you want them, and the Telegram channel carries API changes and incident notes. ## Keep reading - Platform references — endpoint by endpoint - Frequently asked questions — credits, limits, legality - Live playground — run a call in the browser ## Pick a guide and ship something today Each one goes from an empty file to working code. The free tier covers all four end to end. Get an API key Read the docs --- # frequently asked questions URL: https://fastsaverapi.com/faq/ Summary: Site-wide FAQ covering supported platforms, credit pricing, rate limits per plan, the free tier, expiring download URLs, Telegram file_id, private content, legality, the domain change and AI-agent access files. Updated: 2026-08-10 The questions people actually ask before integrating: what FastSaverAPI covers, what a request costs, what the limits really are, and where it deliberately stops. Endpoint-specific questions live on each platform page . ## the basics **Q: What is FastSaverAPI?** A: A REST API that resolves a social-media link into a downloadable file plus its metadata. You send a URL and an API key over HTTPS; you get JSON containing a direct download URL, dimensions, duration and whatever else the platform exposes. It is infrastructure, not an app — there is no interface for end users, only endpoints for your code. **Q: Which platforms are supported?** A: Instagram, TikTok, YouTube, Pinterest, X (Twitter), Facebook and RuTube for media; Shazam for song recognition, charts and lyrics; and a Telegram-specific endpoint that returns a file_id for cached audio. The platforms page links to a reference for each. **Q: Do I need cookies, proxies or a platform login?** A: No, and that is most of the value. Residential proxies, rotating sessions, captcha walls and per-platform scrapers stay on our side of the API. Your side is one HTTPS request with one header. **Q: Is there an SDK or a client library?** A: No, deliberately. Every endpoint is a plain GET or POST with JSON, so requests , fetch , curl or your language's standard HTTP client is enough. A machine-readable description lives at /openapi.json if you want to generate a client. ## plans, credits and limits **Q: What does the free tier include?** A: 1,000 credits, no card, and access to every endpoint — nothing is gated behind a paid plan. The free tier is limited to 10 requests per minute. At 1.5 credits per Instagram or TikTok call that is roughly 660 downloads to evaluate with. **Q: How do credits work?** A: Each request spends credits based on how much work it takes. A TikTok fetch costs 1.5; a Shazam chart lookup costs 1; a 4K YouTube download costs 25. Plans come with a monthly credit allowance rather than a request quota, so cheap calls go further. GET /balance reports what you have left and costs nothing. **Q: What are the rate limits?** A: Per plan: 10 requests per minute on Free, 60 on Pro, 250 on Ultra and 900 on Mega. Exceeding it returns 429 . Custom plans go to 2000+ — ask if you need it. **Q: Can I get a plan bigger than Mega?** A: Yes. Custom credit limits from 5M upward, higher rate limits, dedicated infrastructure with an SLA and white-label arrangements are all available. Message support on Telegram and you will get a quote. ## technical questions **Q: How long does a download_url stay valid?** A: Not long. Most are signed URLs from the platform's own CDN and expire within hours; a YouTube download comes back as a one-shot transfer link on https://api.fastsaver.io/v1/tunnel that is minted for that job and does not outlive it. Fetch the bytes in the same job that made the call. Never store the URL and assume it will resolve tomorrow — store the source link and re-resolve it. **Q: What is a Telegram file_id, and why does it matter?** A: A file_id is a handle to a file already sitting on Telegram's servers. If your bot has one, it can send the file without ever downloading or uploading it — one API call and the user has the track. The alternative is fetching an MP3 to your server and re-uploading it, which costs bandwidth, adds seconds of latency and runs into Telegram's bot upload limits. See the Telegram bot page . **Q: Can I download private or follower-only content?** A: No. The API resolves publicly reachable content. Private accounts, follower-gated posts, close-friends stories and private groups return an error rather than a file, and that is not a limitation we intend to remove. **Q: What happens when a platform changes something?** A: We fix it behind the endpoint. That is the maintenance you are paying to avoid: your integration keeps calling the same path with the same parameters, and the breakage never reaches your code. Notable changes are posted to the Telegram channel . **Q: Is using this legal?** A: The API is a tool; what you do with the media it returns is your responsibility. Downloading content you own, content you have permission to use, or content for personal offline viewing is generally fine. Redistributing someone else's copyrighted work is not, and no API changes that. Check your jurisdiction and each platform's terms before you build a product on top of it. ## about the service **Q: Why did the domain change, and is my key still valid?** A: The API moved from fastsaverapi.com to api.fastsaver.io on 2026-07-24. Keys, endpoint paths and response shapes are unchanged — only the host. Old URLs are 301-redirected. The domain change notice has the full redirect map and a migration checklist. **Q: Can an AI agent read this API without a human?** A: Yes. /llms.txt is a machine-readable index of this site, /llms-full.txt carries the full text, and /openapi.json is an OpenAPI 3.1 description of the API with authentication, parameters and costs. Crawlers and assistants are explicitly allowed in robots.txt . **Q: Where do I get support?** A: Directly from the person who builds it, on Telegram: @coder2077 . There is no ticket queue. Announcements and incident notes go to @fastsaverapi . ## Keep reading - Platform references — what each endpoint returns - Guides — working code, end to end - Domain change notice — fastsaverapi.com → api.fastsaver.io - Current pricing — on api.fastsaver.io ## Still unsure? Test it for free 1,000 credits, no card, every endpoint unlocked. Or paste a link into the playground and read the JSON first. Get a free API key Open the playground --- # Instagram downloader API for reels, posts and stories URL: https://fastsaverapi.com/instagram-downloader-api/ Summary: Instagram media download endpoint: reels, posts, albums, stories and highlights via GET /fetch. Request, full response schema, Python and Node examples, costs, expiry and limits. Updated: 2026-08-10 The Instagram downloader API resolves a public reel, post, carousel, story or highlight with one GET request. You get a direct CDN URL plus dimensions, duration and the caption — no login, no cookie jar, no headless browser. - **Endpoint**: GET /fetch - **Credits**: 1.5 per post · 5 per story - **Auth**: X-Api-Key header - **Returns**: direct CDN URL + metadata ## what the API resolves One endpoint covers everything public on Instagram. You pass the URL exactly as you copied it from the app or the browser, and the API works out what kind of media it points at. - Posts — single photo or video, original resolution, with the caption. - Reels — the MP4 as uploaded, not a re-encode. - Albums / carousels — every slide, each with its own URL and dimensions. - Stories — while they are live, from public accounts. - Highlights — saved story collections, item by item. Short links, /p/ , /reel/ , /tv/ and /stories/ paths all work. Query strings such as ?igsh=… are ignored, so you can paste share links straight from the app. ## the request A single GET. No body, no SDK, no browser. GET https://api.fastsaver.io/v1/fetch 1.5 credits ``` curl "https://api.fastsaver.io/v1/fetch?url=https://www.instagram.com/p/DRsmm9UjKfH/" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "id": "DRsmm9UjKfH", "source": "instagram.com", "type": "video", "download_url": "https://scontent-waw2-1.cdninstagram.com/o1/v/...", "thumbnail_url": "https://scontent-waw2-1.cdninstagram.com/v/...", "width": 1080, "height": 1920, "duration": 12, "caption": "Ferrari 296 GTS top speed run." } ``` Remember to URL-encode the url parameter when you build the request in code — Instagram links carry query strings that will otherwise be read as part of your own. ## the response, field by field | Field | Type | Notes | | --- | --- | --- | | ok | boolean | Always present. false comes with a reason. | | id | string | Instagram shortcode — a stable key for your own cache. | | source | string | instagram.com. Useful when one worker handles every platform. | | type | string | video, image or album. | | download_url | string | Signed CDN URL. Short-lived — fetch it promptly. | | thumbnail_url | string | Poster frame, safe to show before the video loads. | | width / height | integer | Pixel dimensions, so you can size a player without probing the file. | | duration | integer | Seconds. Absent for images. | | caption | string | Original caption text. | Albums are shaped differently: instead of a top-level download_url , width and height , the response carries an items array with one entry per slide, each with its own type , download_url and thumbnail_url . Branch on type === "album" before you read download_url , or you will read undefined on every carousel. ## in your language Nothing here is Instagram-specific beyond the link — the same function works for TikTok, Pinterest, X, Facebook and RuTube. ``` import requests API = "https://api.fastsaver.io/v1/fetch" KEY = "fs_sk_•••••••••••" def fetch(url: str) -> dict: r = requests.get(API, params={"url": url}, headers={"X-Api-Key": KEY}, timeout=60) r.raise_for_status() data = r.json() if not data.get("ok"): raise RuntimeError(data.get("detail", "request failed")) return data media = fetch("https://www.instagram.com/reel/DRsmm9UjKfH/") print(media["type"], media["duration"], media["download_url"]) ``` ``` const res = await fetch( `https://api.fastsaver.io/v1/fetch?url=${encodeURIComponent(link)}`, { headers: { 'X-Api-Key': process.env.FASTSAVER_KEY } } ); const data = await res.json(); if (!data.ok) throw new Error(data.detail ?? 'request failed'); console.log(data.download_url); ``` ## limits worth knowing before you build - Public content only. Private accounts and close-friends stories are out of reach by design. - CDN URLs expire. Download the bytes in the same job, or re-resolve later. - Stories are live-only. After 24 hours they are gone unless the account saved them to a highlight. - Rate limits are per plan — 10 requests per minute on Free, up to 900 on Mega. A 429 means slow down, not that the link is bad. - Instagram changes things. When it does, we ship the fix on our side; your code keeps calling the same endpoint. You are responsible for what you do with the media you download. Respect copyright, the platform's terms and the rights of the person who posted it. ## questions about the Instagram API **Q: Do I need an Instagram login, cookies or a session ID?** A: No. You send the post URL and your API key over plain HTTPS. Sessions, proxies and the login wall are handled on our side, which is the entire reason to use a hosted API instead of running instaloader on a box you have to babysit. **Q: Can it download from private accounts?** A: No, and it should not. The API only resolves content that is publicly reachable. A post from a private account, a close-friends story or anything behind a follow request returns an error rather than a file. **Q: How long does a download_url stay valid?** A: It is a signed URL from Instagram's own CDN and it expires — usually within hours. Fetch the bytes soon after the call, or store the file yourself. Do not persist the URL in a database and expect it to work tomorrow; call /fetch again instead. **Q: What does an Instagram request cost?** A: 1.5 credits for a post, reel or album; 5 credits for stories and highlights, which are more expensive to resolve. On the free tier's 1,000 credits that is roughly 660 reels before you pay anything. **Q: Does it return every image in a carousel?** A: Yes. A carousel comes back with type: "album" and an items array — one entry per slide, each with its own type and download_url . Note that the top-level download_url is absent on albums, so check type first. ## Keep reading - TikTok downloader API — same endpoint, no watermark - Download Instagram reels in Python — guide - All supported platforms — nine references - Live playground — run this call in the browser ## Try it against a real reel Paste a link into the playground and read the JSON before you write a line of code. Open the playground Get a free API key --- # TikTok downloader API for videos, photo posts and short links URL: https://fastsaverapi.com/tiktok-downloader-api/ Summary: TikTok endpoint reference: GET /fetch at 1.5 credits — request format, the three response shapes (video, image, and album with an items array), the music_url and music block returned on every response including albums, short-link resolution, per-field notes, Python and shell examples, region-lock and URL-expiry caveats. Updated: 2026-08-10 The TikTok downloader API turns a post link into direct file URLs with one GET request: /fetch works out whether it is a video, a still or a multi-slide photo post, answers with JSON, and hands you the sound alongside the picture. Short vm.tiktok.com and vt.tiktok.com links resolve exactly as the share sheet copied them. - **Endpoint**: GET /fetch - **Credits**: 1.5 per TikTok link - **Auth**: X-Api-Key header - **Returns**: file URLs, metadata + the audio track ## what the endpoint resolves One parameter: the link. The API works out what sits behind it and answers with JSON — the video file, every still in a photo post, and the sound underneath either. Auth is the X-Api-Key header. - Videos — the clean render, at the uploaded dimensions. - Photo / slideshow posts — each image separately, full size. - The audio track — music_url and a music block on every response, albums included. - Short share links — vm.tiktok.com and vt.tiktok.com , followed to the real post. - Canonical web links — /@handle/video/ , tracking query string or not. The logo and the @handle are composited into the frames before the file leaves TikTok's encoder, so there is no overlay to strip — /fetch resolves the rendition that was never stamped. The watermark guide is where the crop, inpaint and re-encode arithmetic lives, if you want to know why the local routes cost you picture quality. No app, no device emulation, no session to keep warm: if a logged-out browser can open the post, the API can resolve it. ## the request A single GET with the link as a query parameter. URL-encode it: TikTok links carry ?is_from_webapp= and friends, and a raw ampersand ends up in your query string instead of theirs. GET https://api.fastsaver.io/v1/fetch 1.5 credits ``` curl -G "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://www.tiktok.com/@handle/video/7362918273645102345" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "id": "7362918273645102345", "source": "tiktok.com", "type": "video", "download_url": "https://v16-webapp-prime.tiktok.com/video/tos/...", "width": 1080, "height": 1920, "thumbnail_url": "https://p16-sign.tiktokcdn-us.com/tos-...", "duration": 27, "caption": "three takes, one landing", "music_url": "https://sf16-ies-music.tiktokcdn.com/obj/tos-alisg-ve-...", "music": { "id": "7362918019283746105", "title": "original sound", "author": "handle", "cover": "https://p16-amd-va.tiktokcdn.com/img/...", "duration": 27, "album": null, "original": true, "download_url": "https://sf16-ies-music.tiktokcdn.com/obj/tos-alisg-ve-..." } } ``` id is the numeric post ID and a sound primary key if you cache results, though a post that resolves without one falls back to echoing the link you sent. source names the platform that answered, useful when one worker takes every kind of link. duration is seconds — the key is always present, but it is null on photo posts and albums. music_url and music are the sound, covered below . On failure you get ok: false and a detail string. ## photo posts: type image, or type album with items Slideshow posts are not short videos — they are stills with a track underneath, and TikTok stores them that way. type tells you which of three shapes arrived. video and image both carry a top-level download_url with width and height beside it. album carries an items array instead — one entry per slide — and no top-level download_url , width or height at all. Everything after that is identical across the three shapes, the sound included. ``` { "ok": true, "id": "7401882736450192345", "source": "tiktok.com", "type": "album", "items": [ { "type": "image", "download_url": "https://p16-sign.tiktokcdn-us.com/...", "thumbnail_url": "https://p16-sign.tiktokcdn-us.com/...", "width": null, "height": null }, { "type": "image", "download_url": "https://p16-sign.tiktokcdn-us.com/...", "thumbnail_url": "https://p16-sign.tiktokcdn-us.com/...", "width": null, "height": null } ], "thumbnail_url": "https://p16-sign.tiktokcdn-us.com/tos-...", "duration": null, "caption": "berlin, roll three", "music_url": "https://sf16-ies-music.tiktokcdn.com/obj/tos-alisg-ve-...", "music": { "id": "6982174432095817482", "title": "night bus", "author": "Mara Vail", "cover": "https://p16-amd-va.tiktokcdn.com/img/...", "duration": 31, "album": "Low Ceilings", "original": false, "download_url": "https://sf16-ies-music.tiktokcdn.com/obj/tos-alisg-ve-..." } } ``` Each item repeats the still as its own thumbnail_url , so a gallery component that wants a poster field has one without a second pass. Item width and height are usually null for TikTok stills, so size the layout after you have the bytes rather than from the JSON, and the top-level duration is null here — a slideshow has no runtime of its own, only the track's. Branch on type before you reach for a file: code that reads download_url unconditionally throws on every album, code that only handles album misses the single-still posts that come back as image , and someone will paste both on day one. ## the music block, which only TikTok returns Every TikTok response ships the sound as well as the picture, without a second call or a parameter to ask for it: music_url is a direct link to the audio file, and music is the metadata around it. Videos, single stills and albums all get it. No other platform on /fetch returns anything like it, so if your product is about sounds rather than clips, this is the field to build on. - music_url — the audio file itself, identical to music.download_url . When a post has no resolvable audio, it and the whole music object are null , so null-check before you index into it. - music.title and music.author — the sound as TikTok credits it: a licensed track's name and artist, or original sound and the creator's handle when they recorded it themselves. - music.cover — the sound's own artwork, which is not the post thumbnail_url . - music.duration — the track's length in seconds. It will not always match the post's duration . - music.album and music.original — the album name where a licensed track has one, and whether the sound started with this creator. - music.id — TikTok's ID for the sound. Two posts using the same sound share it, which makes it the key to group by if you are tracking what is trending. The names degrade independently of the URL. On some posts the block arrives with download_url and duration filled in while title , author , cover and album come back null — the file is still there, only the credits are missing. Read them defensively, and do not key a cache on music.title . ## resolve, then save Two steps, always: resolve the link, then pull the bytes before the signature ages out. This handles all three shapes plus the track, and streams to disk rather than buffering the clip in memory. ``` import requests from pathlib import Path API = "https://api.fastsaver.io/v1/fetch" HEAD = {"X-Api-Key": "fs_sk_•••••••••••"} def resolve(link: str) -> dict: r = requests.get(API, params={"url": link}, headers=HEAD, timeout=60) data = r.json() if not data.get("ok"): raise RuntimeError(data.get("detail", "link could not be resolved")) return data def save(url: str, dest: Path) -> None: with requests.get(url, stream=True, timeout=120) as r: r.raise_for_status() with dest.open("wb") as f: for chunk in r.iter_content(64 * 1024): f.write(chunk) post = resolve("https://vm.tiktok.com/ZMAvfLFYc/") if post["type"] == "album": for i, item in enumerate(post["items"]): save(item["download_url"], Path(post["id"] + "_" + str(i) + ".jpg")) elif post["type"] == "image": save(post["download_url"], Path(post["id"] + ".jpg")) else: save(post["download_url"], Path(post["id"] + ".mp4")) # the sound comes with every shape, albums included if post.get("music_url"): save(post["music_url"], Path(post["id"] + ".mp3")) ``` One-off from a terminal, with jq: ``` curl -sG "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://vt.tiktok.com/ZSAvfLFYc/" \ -H "X-Api-Key: fs_sk_•••••••••••" \ | jq -r '.download_url' \ | xargs curl -Lo clip.mp4 ``` That shell one-liner assumes a video. For an album, jq -r '.items[].download_url' gives you the slide URLs one per line, and jq -r '.music_url' gives you the sound whatever the shape. ## where TikTok links stop resolving - Public posts only. Private accounts, friends-only posts and anything behind a login return an error, never a file. - Region locks are real. A post restricted to a market we cannot reach fails with a reason — better an error than a substitute clip you ship to a user. - Deleted is deleted. Removed or moderated posts are not recoverable from a cache; the failure is permanent, so do not queue a retry. - Signed URLs go stale. TikTok CDN links are short-lived. If you need the file next week, store the file, not the URL. - Rate limits scale with the plan — ten requests a minute on Free, nine hundred on Mega. A 429 is back-pressure, not a broken link. - TikTok moves. Playback hosts and link formats change every few months. Those repairs land on our side; your request shape stays put. What you may then do with a resolved file is a copyright question rather than an API question. Sort that out before you republish anything. ## questions about the TikTok API **Q: What does the TikTok downloader API return for one link?** A: A JSON object with ok , the post id , a type of video , image or album , and the file URLs for that shape. Videos and single stills carry a top-level download_url ; multi-slide posts carry an items array instead. Every shape also carries thumbnail_url , duration , caption and the post's audio as music_url plus a music object. **Q: Do vm.tiktok.com and vt.tiktok.com share links work?** A: Yes. Both are followed server-side to the canonical /@handle/video/ post before anything resolves, so paste whatever the share sheet copied. **Q: Can the API download TikTok photo and slideshow posts?** A: It can. A multi-slide post resolves with type set to album and an items array holding one entry per still, each with its own download_url . A photo post with a single still comes back as type image with a plain top-level download_url and no items . **Q: Can I get the audio track from a TikTok post?** A: Yes, and you do not have to ask for it. Every TikTok response carries music_url — a direct link to the sound file — next to a music object with its title, author, cover art, album and duration. Albums and single stills get the track too, which is the point: a slideshow is images plus a sound, and the sound is the half most downloaders drop. **Q: Why does a TikTok link return an error instead of a video?** A: Almost always because the post is not publicly reachable: deleted, taken down, set to private or friends-only, or restricted to a region. The response says so with ok: false and a detail string rather than handing you a placeholder file. **Q: What does one TikTok download cost?** A: 1.5 credits per resolved call, the same as Pinterest, X and Facebook. The free tier's 1,000 credits cover roughly 660 posts — enough to prototype before you pick a plan. A link that fails to resolve is billed at 0.1 credits rather than nothing, so a retry loop over dead posts still costs you something. ## Keep reading - TikTok without the watermark — why local removal costs quality - Instagram downloader API — reels, posts, stories - All supported platforms — same endpoint, nine sources - Endpoint reference — run the call in the playground ## Resolve your first TikTok link Paste a share link into the playground and read the JSON before you commit to an integration. Open the playground Get a free API key --- # YouTube downloader API — from format lookup to a 4K file URL: https://fastsaverapi.com/youtube-downloader-api/ Summary: YouTube API reference: GET /youtube/info (2 credits, formats with file sizes), GET /youtube/search (2 credits, YouTube Music, 10 results per page, pages 1-3), POST /youtube/download (15 credits, 25 for 1440p/2160p, formats audio through 2160p, download_url is https://api.fastsaver.io/v1/tunnel?id=... on the same api.fastsaver.io host and expires after about 10 minutes), POST /youtube/audio/tg-bot (7 credits on a cache hit, 15 when the track has to be downloaded first, returns a Telegram file_id). A failed resolve costs 0.1 credits and answers 400 — with the info-then-download flow, a Python example, costs and limits. Updated: 2026-08-10 Four endpoints make up the YouTube downloader API: read a video's formats and file sizes, download anything from 144p to 2160p (4K), take the audio track on its own, or search YouTube Music. Check the info call before you spend credits on a download. - **Endpoints**: four — info , search , download , audio/tg-bot - **Credits**: 2 info · 2 search · 15 download · 25 at 2K/4K · tg-bot audio 7 cached, 15 on a miss - **Formats**: audio, 144p → 2160p (4K) - **Auth**: X-Api-Key header ## four endpoints, and what each one is for YouTube does not go through the universal /fetch endpoint. The work is different: formats have to be enumerated, streams muxed, and the result is a file we produce rather than a link we forward. | Endpoint | Method | Credits | Use it when | | --- | --- | --- | --- | | /youtube/info | GET | 2 | Title, author, duration, thumbnails and every available format with its file size. | | /youtube/search | GET | 2 | Tracks from YouTube Music — 10 per page, pages 1 to 3. | | /youtube/download | POST | 15 · 25 | The file itself, at a resolution you choose, or the audio alone. | | /youtube/audio/tg-bot | POST | 7 · 15 | A Telegram file_id instead of bytes. | All four take the same X-Api-Key header. The GETs put parameters in the query string; the POSTs take a JSON body. /youtube/audio/tg-bot is the one row with two prices, and the difference is worth budgeting for. If we already hold that video_id , you pay 7 credits and get a file_id back almost immediately. If we do not, the request falls through to a real download and bills the full 15 — the same as /youtube/download with format: "audio" . A cold catalogue costs 15 a track; it is the second and later requests for the same track that cost 7. ## check the formats before you spend fifteen credits The correct order is info, then download. /youtube/info costs 2 credits and answers two things up front: which resolutions this video actually has, and how big each one is. A download that succeeds and hands you a four-gigabyte file nobody asked for still bills 15 — seven and a half times the lookup that would have talked you out of it. GET https://api.fastsaver.io/v1/youtube/info 2 credits ``` curl -G "https://api.fastsaver.io/v1/youtube/info" \ --data-urlencode "url=https://www.youtube.com/watch?v=HanBb8FonWs" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "video_id": "HanBb8FonWs", "title": "Snails - Frogbass", "author": "DubstepGutter", "author_url": "https://www.youtube.com/channel/UCG6QEHCBfWZOnv7UVxappyw", "thumbnail": "https://i.ytimg.com/vi/HanBb8FonWs/sddefault.jpg", "duration": 234, "thumbnails": { "low": "https://i.ytimg.com/vi/HanBb8FonWs/mqdefault.jpg", "max": "https://i.ytimg.com/vi/HanBb8FonWs/maxresdefault.jpg" }, "formats": [ { "type": "video", "format": "144p", "filesize": 6967209 }, { "type": "video", "format": "360p", "filesize": 21096358 }, { "type": "video", "format": "720p", "filesize": 60448393 } ] } ``` The -G and --data-urlencode matter. A watch URL carries its own ?v= , so pasting it raw into the query string splits it: the server reads url=https://www.youtube.com/watch plus a stray v parameter, finds no video id and errors. In code, let your HTTP client encode it — params= in requests, encodeURIComponent in JavaScript. A youtu.be/ID link has no query string and needs none of this. filesize is in bytes. Render your quality picker from this array — a 4K button on a video that tops out at 720p is a support ticket. Then ask for the file. POST https://api.fastsaver.io/v1/youtube/download 15 credits · 25 at 2K/4K ``` curl -X POST "https://api.fastsaver.io/v1/youtube/download" \ -H "X-Api-Key: fs_sk_•••••••••••" \ -H "Content-Type: application/json" \ -d '{"url": "https://www.youtube.com/watch?v=HanBb8FonWs", "format": "1080p"}' ``` ``` { "ok": true, "video_id": "HanBb8FonWs", "duration": 234, "filename": "Snails - Frogbass (1080p, av1, youtube).mp4", "download_url": "https://api.fastsaver.io/v1/tunnel?id=3f7a1c9e-52b4-4d18-9f0a-6c2b7e51d8aa", "thumbnails": { "low": "https://i.ytimg.com/vi/HanBb8FonWs/mqdefault.jpg", "max": "https://i.ytimg.com/vi/HanBb8FonWs/maxresdefault.jpg" } } ``` Two fields deserve a closer look. - filename arrives as title (format, codec, source).mp4 , so av1 in the name is the codec you actually got. Fine for a Content-Disposition header; sanitise it before it touches a filesystem, because it carries whatever the uploader typed. - download_url is https://api.fastsaver.io/v1/tunnel?id=… — the same api.fastsaver.io host you just called, on the /v1/tunnel path, not a YouTube CDN link. The file is muxed on our side, so there is no signed googlevideo URL to hand you. The tunnel answers with a redirect to the file, so your client has to follow redirects ( curl -L ). Stream it straight through to storage or to your user. ## formats and what they cost format is required and takes exactly one of these values. There is no "best" or "auto" — you pick, which means you control the bill. | format | What you get | Credits | | --- | --- | --- | | audio | Audio track only, no video stream. | 15 | | 144p | Smallest file. Previews. | 15 | | 240p | Low bandwidth. | 15 | | 360p | Watchable on a phone. | 15 | | 480p | SD. | 15 | | 720p | HD. Sensible default. | 15 | | 1080p | Full HD. | 15 | | 1440p | 2K. | 25 | | 2160p | 4K. | 25 | Only 2K and 4K break the flat rate, and not for the sake of tiering. A 4K download moves several times the data of a 1080p one and holds a worker longer while it does. We pass that through instead of averaging it into every request, so 720p users are not subsidising 4K users. ## search youtube music, then deliver /youtube/search queries music.youtube.com, not the main site, so results skew to tracks rather than reaction videos and hour-long mixes. Ten per page; page is required and accepts 1, 2 or 3. Thirty results is the ceiling. GET https://api.fastsaver.io/v1/youtube/search 2 credits · 10 results ``` curl "https://api.fastsaver.io/v1/youtube/search?query=the%20weeknd&page=1" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "page": 1, "results": [ { "video_id": "HgLfYfJtc8M", "title": "The Weeknd - One Of The Girls", "duration": "4:05", "thumbnail": "https://i.ytimg.com/vi/HgLfYfJtc8M/mqdefault.jpg", "thumbnail_max": "https://i.ytimg.com/vi/HgLfYfJtc8M/maxresdefault.jpg" } ] } ``` duration here is a display string like "4:05" , not the integer seconds that /youtube/info and /youtube/download return. Parse accordingly. video_id is the join key: build a watch URL from it for /youtube/download , or hand it to the Telegram bot media API , which documents file_id delivery in full. ## the whole flow in one function Pick the highest resolution the video offers that fits a size budget, then download it. Two calls, one key, nothing to install on your side. ``` import requests BASE = "https://api.fastsaver.io/v1" HEADERS = {"X-Api-Key": "fs_sk_•••••••••••"} LADDER = ["2160p", "1440p", "1080p", "720p", "480p", "360p"] def best_format(url, max_bytes=250_000_000): info = requests.get(f"{BASE}/youtube/info", params={"url": url}, headers=HEADERS, timeout=60).json() sizes = {f["format"]: f["filesize"] for f in info.get("formats", [])} for quality in LADDER: if quality in sizes and sizes[quality] <= max_bytes: return quality return "360p" def download(url): quality = best_format(url) job = requests.post(f"{BASE}/youtube/download", headers=HEADERS, json={"url": url, "format": quality}, timeout=300).json() if not job.get("ok"): raise RuntimeError(job.get("detail", "download failed")) with requests.get(job["download_url"], stream=True, timeout=300) as r: r.raise_for_status() with open(job["filename"], "wb") as fh: for chunk in r.iter_content(1 << 20): fh.write(chunk) return job["filename"] print(download("https://www.youtube.com/watch?v=HanBb8FonWs")) ``` Note the timeouts. A 4K mux is not a 200 ms JSON call; a default five-second client timeout will abandon a request that was going to succeed. Stream the body rather than buffering gigabytes. From a shell, the same lookup is one pipe: ``` API="https://api.fastsaver.io/v1" curl -s "$API/youtube/info?url=https://youtu.be/HanBb8FonWs" \ -H "X-Api-Key: $FS_KEY" | jq '.formats[] | select(.format == "1080p")' ``` ## where the YouTube endpoints stop - Public videos only. If a signed-out browser cannot play it, the API cannot resolve it. Gated content returns an error, not a file. - A download_url lives about ten minutes. The id in https://api.fastsaver.io/v1/tunnel is held for 10 minutes and then dropped; after that the URL 404s with tunnel.not_found . Fetch the bytes in the same run; do not cache the string. - Not every video has every format. /youtube/info is the only honest answer to "is 4K available", and it costs 2 credits to ask. - Failures are cheap, not free. A private video, a dead link or a broken extraction still charges a flat 0.1 credits on any of the four endpoints, and answers 400. Nothing next to 15, but a retry loop over a video that will never resolve does bill. - Big downloads take real time. Budget for it in your client timeout and your worker queue. - Requests per minute follow the plan — 10 on Free, 900 on Mega. A 429 is a pacing signal: widen the gap between jobs rather than retrying the same one harder. - YouTube changes things. Extraction breaks on their schedule. We fix it behind the same endpoint; your code does not move. What you download is your responsibility. Most of YouTube is somebody's copyrighted work, and an API key is not a licence. ## questions about the YouTube API **Q: How do I download a YouTube video in 4K through an API?** A: POST to /youtube/download with {"url": "…", "format": "2160p"} and you get a download_url back. 2160p is 4K, 1440p is 2K, and both bill at 25 credits instead of 15. Not every video has a 4K master, so check /youtube/info first. **Q: Can I get only the audio, without the video track?** A: Yes — POST /youtube/download with format: "audio" . The YouTube to MP3 guide covers the Telegram path and the cost comparison. **Q: How long does the download_url stay valid?** A: About ten minutes. It points at https://api.fastsaver.io/v1/tunnel?id=… — the same api.fastsaver.io host you called, on the /v1/tunnel path — and that id is held for 10 minutes, then dropped. After that the URL answers 404 tunnel.not_found . Pull the bytes in the same worker run; a URL cached yesterday will not resolve today. **Q: Why do 1440p and 2160p cost 25 credits instead of 15?** A: Because they cost us more. A 4K stream is several times the bytes of a 1080p one and holds a worker longer. If your users watch on a phone, 1080p at 15 credits is the better trade. **Q: Can I search YouTube Music and download the result in one flow?** A: Yes. /youtube/search returns 10 tracks per page, each with a video_id , for 2 credits. Feed that id to /youtube/audio/tg-bot — 7 credits if we already have the track, 15 if we have to fetch it first — or build a watch URL from it for /youtube/download . Pages 1–3 only, so 30 results per query. ## Keep reading - YouTube to MP3 over HTTP — guide - Telegram bot media API — file_id delivery - Build a Telegram music bot — search + send - All supported platforms — nine references ## Run a 4K download before you commit Paste a video URL into the playground, pick a format and read the JSON. The free tier covers 66 standard downloads. Open the playground Get a free API key --- # Telegram bot media API — audio without the re-upload URL: https://fastsaverapi.com/telegram-bot-media-api/ Summary: Telegram bot media API reference: POST /youtube/audio/tg-bot returns a bot-scoped Telegram audio file_id for 7 credits when the track is already cached and 15 when it has to be downloaded first, removing the download-and-reupload loop and the 50 MB bot upload limit. Covers what a file_id is, why it is bound to one bot_username, the 400/detail error surface (no 402) and the 0.1-credit failure charge, a minimal aiogram send and the other endpoints that pair with it. Updated: 2026-08-10 The Telegram bot media API turns a YouTube video ID into a Telegram file_id , so your bot answers with a string instead of a file. Millions of tracks are already cached and cost 7 credits; a track we have to fetch first is slower and costs 15. Charts, search and song recognition from the same API all hand you an ID this call accepts. - **Endpoint**: POST /youtube/audio/tg-bot - **Credits**: 7 on a cache hit, 15 on a miss - **Returns**: a Telegram file_id - **Scope**: valid only for the bot_username you send ## what a file_id is, precisely When a bot sends a file, Telegram stores it and returns an identifier: a file_id , an opaque string like CQACAgIAAxkDAAIVKWk… . Not a URL, not a hash of the audio — a pointer into Telegram's storage, and the cheapest thing a bot can send, because sending it moves no bytes through your infrastructure. Two properties shape the design. A file_id is bound to one bot; Telegram will not accept one bot's identifier from another. The same file also carries a file_unique_id that is stable across bots, but you cannot send or download with it — it only tells you two references point at the same audio. The trade: pay once for a bot-scoped reference, and every later send of that track is one ordinary Telegram call carrying a string. Store the ones you get — the first resolve is the only part that costs credits, and it costs more than you might expect when the track is new to us. ## the loop this replaces Otherwise, delivering one song means: resolve the track, stream it from a CDN to your server, hold it in memory or on disk, upload the same bytes to Telegram as multipart form data, then keep the file_id so the next user is cheaper. - Bandwidth is doubled. Every megabyte arrives at your server and leaves again. On metered egress that is the cost of the feature. - The upload leg is the slow one. Pulling from a CDN is fast; pushing to Telegram from a small VPS usually is not. - 50 MB is a hard ceiling. The Bot API refuses larger uploads, so long mixes cannot be delivered this way at all. - Concurrency becomes capacity planning. Twenty requests at once means twenty in-flight files, temp-file cleanup and a worker queue. The tg-bot endpoint removes all four: the file never touches your process. ## the request One POST, two fields. video_id is the YouTube or YouTube Music ID — the eleven characters, not the whole link — and bot_username is your bot, with the @ . POST https://api.fastsaver.io/v1/youtube/audio/tg-bot 7 cached · 15 on a miss ``` curl -X POST https://api.fastsaver.io/v1/youtube/audio/tg-bot \ -H "X-Api-Key: fs_sk_•••••••••••" \ -H "Content-Type: application/json" \ -d '{"video_id": "vk6014HuxcE", "bot_username": "@your_bot"}' ``` ``` { "ok": true, "file_id": "CQACAgIAAxkDAAIVKWk..." } ``` That is the entire response: a delivery handle, no title, no artist, no duration. Keep the metadata from whatever call gave you the video_id — you will want it for the caption. The same response shape covers both prices. If we already hold the track the call is 7 credits and returns almost immediately; if we do not, the track is downloaded first and the call is billed at the YouTube download rate of 15. Nothing in the payload tells you which happened, so read your spend from GET /balance or your dashboard rather than assuming. Treat ok as the success flag rather than the status code. A refusal arrives as a plain 400 with a detail string — an exhausted balance reads Insufficient credits. Please top up to your account. , and there is no 402 to branch on. 401 means the key never arrived, 429 that you are over your plan's per-minute limit. A failed resolve is not free either: every failure path charges a flat 0.1 credits, so a retry loop over an ID that will never resolve still costs something. ## the smallest bot that sends one The endpoint reduces to two lines of bot code: POST the video ID, pass the returned string to answer_audio . Everything below is scaffolding around those two lines. ``` import asyncio, os import httpx from aiogram import Bot, Dispatcher, F from aiogram.types import Message API = "https://api.fastsaver.io/v1" BOT_USERNAME = "@your_bot" bot = Bot(os.environ["BOT_TOKEN"]) dp = Dispatcher() http = httpx.AsyncClient( headers={"X-Api-Key": os.environ["FASTSAVER_KEY"]}, timeout=120, ) @dp.message(F.text.regexp(r"^[\w-]{11}$")) async def on_video_id(msg: Message) -> None: r = await http.post( f"{API}/youtube/audio/tg-bot", json={"video_id": msg.text, "bot_username": BOT_USERNAME}, ) data = r.json() if not data.get("ok"): await msg.answer(data.get("detail", "could not resolve that ID")) return await msg.answer_audio(data["file_id"]) # a string, not a file if __name__ == "__main__": asyncio.run(dp.start_polling(bot)) ``` On python-telegram-bot the send becomes await update.message.reply_audio(audio=file_id) . Either way, the argument that would normally be an open file object is a string. This is deliberately the endpoint and nothing else. Search, an inline keyboard, storing the strings and the error paths belong to a real bot rather than to the call — the Telegram music bot guide builds that end to end. ## what else fits a bot A bot is rarely just a downloader. Most other endpoints hand you a video_id that goes straight into the tg-bot call. - GET /youtube/search — 2 credits. Ten results per page, pages 1 to 3, each with video_id , title , duration and thumbnails: enough to label a row without a second lookup. - GET /shazam/top — 1 credit. Charts by country code or worldwide; results already carry video_id . - POST /shazam/identify — 5 credits, mp3/m4a/ogg/mp4 up to 50 MB. Bots may download from Telegram up to 20 MB, so a forwarded voice note fits: pull it with getFile , post it, offer the results back. - GET /shazam/lyrics — 2 credits, by title and artist . Both are required query parameters — there is no lookup by ID — and the reply carries ok and lyrics and nothing else, so keep the title and artist you already have for the header of your "lyrics" button. - POST /youtube/download with format: "audio" — 15 credits, when you need the file rather than a reference. - GET /balance — free. Poll it from a health check; learn about an empty balance before your users do. GET /fetch covers the other platforms with a direct download_url . Telegram will fetch a remote URL for you, but its ceiling there is lower than the upload one — a convenience, not a strategy. ## what a file_id will not do for you - One bot, one file_id. Swap or add a bot and every reference must be re-requested, and each re-request is charged again. Keep bot_username alongside the string. - Public content only. Public YouTube and YouTube Music IDs resolve. Private, members-only and region-blocked videos do not. - A miss is slower and costs 15 instead of 7. A cache hit hands back a track we already hold; a miss means downloading it first, and that is billed at the YouTube download rate. Storing the file_id yourself therefore saves real money and not just latency: every re-resolve you avoid is 7 credits you keep, and the first resolve of a track nobody has asked for yet is 15. - Two rate limits apply. Ours is per plan — 10 requests a minute on Free, 60 on Pro, up to 900 on Mega. Telegram runs its own flood control on top of that. - References are not permanent. A file_id points into storage you do not own. On a failed send, request a fresh one. - Platforms move. When YouTube changes something the fix ships on our side; your request shape stays put. ## questions about Telegram bot delivery **Q: What exactly is a Telegram file_id?** A: An opaque string Telegram issues for a file already sitting on its servers. A handle, not a URL: nothing to open, nothing to download. You put it where the file would go in sendAudio and Telegram serves the bytes. **Q: Can I reuse the same file_id in a second bot?** A: No. It is scoped to one bot; hand it to another and Telegram rejects the send with a bad-request error about the file identifier. Hence bot_username being required. Two bots means requesting the track twice and keeping two strings. **Q: What does sending a track through a Telegram bot cost?** A: 7 credits per call to POST /youtube/audio/tg-bot when we already hold the track, 15 when we do not and have to download it first — a miss is billed as a YouTube download. Add 2 for GET /youtube/search if that is how you found the ID. Hold on to the returned string and later sends are ordinary Telegram calls that spend nothing. **Q: What happens when the track is not already cached?** A: It is downloaded fresh and you still get a file_id — but the call costs 15 credits instead of 7, because there is nothing to reference until the download finishes. It is slower for the same reason. Millions of tracks are pre-cached, so misses are the exception — but budget for them, and give the HTTP call a read timeout measured in minutes, not the handful of seconds most clients default to. **Q: Can a Telegram bot send audio larger than 50 MB?** A: Not by uploading it — the Bot API caps a bot upload at 50 MB, which a long mix or podcast episode exceeds. A file_id sidesteps that: your bot uploads nothing, it references a file Telegram already holds. Self-hosting a Bot API server raises the ceiling, but that server is then yours to keep alive. ## Keep reading - Build a Telegram music bot — search, keyboard, cache, errors - YouTube downloader API — info, search, download - Shazam API — identify a voice note - Endpoint reference & playground — run the call in the browser ## Send your first file_id Free tier is 1,000 credits — around 140 cached tracks, or 66 if every one is new to the cache. Get an API key Open the playground --- # Shazam API for song recognition from an audio clip URL: https://fastsaverapi.com/shazam-api/ Summary: Shazam song recognition endpoints: POST /shazam/identify for audio fingerprinting from an uploaded mp3/m4a/ogg/mp4 file, GET /shazam/top for country charts and GET /shazam/lyrics by required title and artist query parameters, with the recognise-to-Telegram-audio pipeline, credit costs and accuracy limits. Updated: 2026-08-10 The Shazam API brings song recognition to your own app: post a few seconds of audio and get the title, artist, cover art, lyrics and a YouTube video_id you can turn into a playable file. The same key reads Shazam charts by country and looks up lyrics for any title and artist pair you already hold. - **Endpoints**: POST /shazam/identify · GET /shazam/top · GET /shazam/lyrics - **Credits**: 5 identify · 2 lyrics · 1 charts - **Upload**: mp3, m4a, ogg, mp4 — 50 MB ceiling - **Returns**: title, artist, lyrics + YouTube matches ## three endpoints, one music stack Recognition on its own is rarely the product. A user sends a clip, your app names the song, then hands back something playable. These three endpoints cover the first two thirds of that; the YouTube side of the API covers the rest. ### identify 5 credits Upload audio, get the track back with its Shazam id, artwork, lyrics and YouTube matches. ### top 1 credit Shazam charts for one country code or world , ten tracks per page. ### lyrics 2 credits Lyrics for a track you can already name — pass title and artist . All three authenticate with the same X-Api-Key header and answer with { "ok": true, … } . Only identify is a POST, and only identify sends bytes. ## identifying a track from audio Send the file as multipart/form-data under the field name file . There is no URL variant — the audio has to reach the recogniser somehow. POST https://api.fastsaver.io/v1/shazam/identify 5 credits · multipart ``` curl -X POST "https://api.fastsaver.io/v1/shazam/identify" \ -H "X-Api-Key: fs_sk_•••••••••••" \ -F 'file=@clip.m4a;type=audio/x-m4a' ``` ``` { "ok": true, "id": "102147933", "title": "Сердце пацана", "artist": "YARMAK", "thumbnail": "https://is1-ssl.mzstatic.com/image/thumb/...", "lyrics": "...", "results": [ { "video_id": "vk6014HuxcE", "title": "YARMAK - Сердце пацана", "duration": "3:24", "thumbnail": "https://i.ytimg.com/vi/vk6014HuxcE/mqdefault.jpg", "thumbnail_max": "https://i.ytimg.com/vi/vk6014HuxcE/maxresdefault.jpg" } ] } ``` | Field | Type | Notes | | --- | --- | --- | | id | string | Shazam track id. A stable key for your own cache — note that /shazam/lyrics does not accept it. | | title | string | Track name as Shazam has it, in the original script. | | artist | string | Credited performer, not the uploader. | | thumbnail | string | Cover art URL, ready to show in a reply. | | lyrics | string | Included when Shazam has them, so a hit often needs no second call. | | results | array | YouTube matches, each with video_id, title, duration and two thumbnail sizes. | Treat results as search output, not as something the recogniser vouched for. It is ordered best-first, but a popular track also surfaces covers, sped-up edits and hour-long loops. Compare duration and title against the recognised track before you auto-deliver the first entry. ## recognise → video_id → Telegram audio This is what most people build. A user forwards a voice note or a video to your bot; you reply with the song as a proper audio file. Two API calls, no file ever touches your disk, and 12 credits once the track is cached. ``` import requests API = "https://api.fastsaver.io/v1" AUTH = {"X-Api-Key": "fs_sk_•••••••••••"} BOT = "@your_bot" TELEGRAM_TOKEN = "123456789:AA-your-bot-token" TG = "https://api.telegram.org/bot" + TELEGRAM_TOKEN def identify(path: str) -> dict | None: with open(path, "rb") as fh: r = requests.post(API + "/shazam/identify", headers=AUTH, files={"file": fh}, timeout=120) data = r.json() return data if data.get("ok") and data.get("results") else None def telegram_audio(video_id: str) -> str: r = requests.post(API + "/youtube/audio/tg-bot", headers=AUTH, json={"video_id": video_id, "bot_username": BOT}, timeout=180) return r.json()["file_id"] def deliver(chat_id: int, path: str) -> None: song = identify(path) if song is None: requests.post(TG + "/sendMessage", json={ "chat_id": chat_id, "text": "No match. Try a cleaner few seconds.", }) return file_id = telegram_audio(song["results"][0]["video_id"]) requests.post(TG + "/sendAudio", json={ "chat_id": chat_id, "audio": file_id, "caption": song["artist"] + " — " + song["title"], }) if __name__ == "__main__": deliver(123456789, "clip.m4a") ``` The second step returns a Telegram file_id , not a download URL. Telegram already holds the bytes, so sendAudio resolves instantly and your bandwidth stays flat. The id is bound to the bot_username you passed, so use the real one even while testing. The Telegram music bot guide covers the search-driven variant. Budget for the miss, not the hit. /youtube/audio/tg-bot charges 7 credits only when we already hold that video_id ; if we do not, it downloads the track first and bills the full 15 of a YouTube download. So the pipeline is 12 credits for a song someone has asked for before and 20 for one nobody has — a difference that shows up as soon as your users start finding obscure music rather than the same twenty chart tracks. ## charts and standalone lyrics /shazam/top is the cheapest call in the API and the easiest way to seed a music app with content nobody has to search for. Ten tracks per page, pages 1 to 3. Pass a country code such as us , gb , ru or uz , or world for the global chart. GET https://api.fastsaver.io/v1/shazam/top 1 credit ``` curl "https://api.fastsaver.io/v1/shazam/top?country=world&page=1" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "results": [ { "video_id": "7nVctvQVz0U", "title": "Taylor Swift - The Fate of Ophelia", "duration": "3:47", "thumbnail_url": "https://i.ytimg.com/vi/7nVctvQVz0U/mqdefault.jpg" }, { "video_id": "9_bTl2vvYQg", "title": "HUNTR/X - Golden", "duration": "3:15", "thumbnail_url": "https://i.ytimg.com/vi/9_bTl2vvYQg/mqdefault.jpg" } ] } ``` Chart rows already carry a video_id , so a trending list feeds the same delivery step as a recognition. Note the field is thumbnail_url here and thumbnail in the identify results; map both into your own model rather than passing raw payloads around. Lyrics have their own endpoint for when you already know the track. It is a lookup by name, not by id: title and artist are both required query parameters, and leaving either one out fails validation before the request ever reaches the lyrics provider. Send the strings exactly as /shazam/identify gave them to you, URL-encoded. GET https://api.fastsaver.io/v1/shazam/lyrics 2 credits ``` curl -G "https://api.fastsaver.io/v1/shazam/lyrics" \ --data-urlencode "title=Сердце пацана" \ --data-urlencode "artist=YARMAK" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "lyrics": "..." } ``` That is the entire payload — ok and lyrics . Nothing echoes the track back, and there is no language field, so a right-to-left layout or a translation offer has to be decided from metadata you already hold rather than from this response. Because the match is on the strings you send, a reformatted title or a rewritten "feat." credit can come back empty for a song that does have lyrics: keep the pair verbatim from identify and cache it alongside the track. ## what recognition actually needs Be blunt with yourself about the input. Fingerprinting compares a spectral signature against an index; it does not listen the way you do. A short, clean excerpt beats a long, dirty one. - Trim before uploading. Five to fifteen seconds of the strongest passage. A full track costs upload time and does not raise the hit rate. - Pick the musical part. A clip dominated by talking, applause or wind is a coin flip. - Do not re-encode down. A phone recording is fine; a 32 kbps recompression of one is worse. - Edits break matches. Pitch-shifted, sped-up and heavily filtered versions often return no match, or the wrong release of the right song. - Obscure music may be absent. Unreleased tracks, private DJ edits and most library music are not in the index. There is nothing to return. If you accept video, cut the audio on your worker first — one ffmpeg call. ``` ffmpeg -i input.mp4 -ss 00:00:20 -t 12 -vn -c:a aac -b:a 128k clip.m4a ``` ## upload ceilings, missed matches and chart depth - 50 MB and four containers. mp3, m4a, ogg and mp4. Anything else, convert first. - No match is a normal outcome. Design that path first — a "try a clearer clip" reply is part of the product, not an edge case. - YouTube matches are search results. Verify before auto-sending, especially for tracks with heavy remix traffic. - Charts are a snapshot. Pages 1–3, ten rows each, reordering through the day. Cache with a short TTL. - Throughput is capped by plan — from 10 requests a minute on Free up to 900 on Mega. Uploads are the slowest call here, so queue them instead of firing a batch in parallel. - Lyrics and artwork are third-party metadata. Availability varies by track and region, and they are not yours to present as your own. Whatever you upload, and whatever you then do with the match, is on you. Naming a song is not a licence to redistribute it. ## questions about the Shazam API **Q: How do I identify a song from an audio file with an API?** A: POST the file as multipart/form-data to /shazam/identify under the field name file — mp3, m4a, ogg or mp4, up to 50 MB. You get back the Shazam id , title , artist , thumbnail , lyrics and a results array of matching YouTube tracks. **Q: How long should the audio clip be for reliable recognition?** A: Five to fifteen seconds of the clearest passage. Recognition matches a fingerprint window, so a full track adds upload time and nothing else. If the clip is mostly crowd noise or speech, cut to where the music dominates. **Q: Can I fetch lyrics without running recognition again?** A: Yes, as long as you can already name the track. GET /shazam/lyrics takes title and artist as required query parameters — both, or the request fails validation — and costs 2 credits instead of 5. The response is { "ok": true, "lyrics": "…" } and nothing else: no title, no artist and no language field come back. Cache the title and artist from the first recognition and you never pay to identify the same clip twice. **Q: How do I send the recognised song to a Telegram user as an audio file?** A: Pass results[0].video_id to /youtube/audio/tg-bot with your bot username. You get a Telegram file_id that your bot hands to sendAudio — nothing downloaded to your server, nothing uploaded to Telegram. That step bills 7 credits when the track is already in our cache and 15 when it is not, because a miss falls through to a full YouTube download. **Q: What does song recognition cost per request?** A: 5 credits to identify, 2 for a lyrics lookup by title and artist, 1 for a page of charts. Recognise-then-deliver costs 12 when the matched track is already cached and 20 the first time anyone asks for it, since the Telegram step is 7 cached and 15 on a miss. On the free tier's 1,000 credits that is about 83 repeat lookups, or 50 if every track is new to the cache. ## Keep reading - Telegram bot media API — the file_id delivery step - Build a Telegram music bot — guide - YouTube downloader API — search, info and audio - All supported platforms — nine references ## Upload a clip and see the JSON The playground takes a file directly — drop in twelve seconds of audio and read the match before you write any code. Try it with a file Get a free API key --- # Pinterest downloader API for images, videos and idea pins URL: https://fastsaverapi.com/pinterest-downloader-api/ Summary: Pinterest media download endpoint: image pins, video pins and multi-page idea pins via GET /fetch at 1.5 credits, covering pin.it short links, the album envelope with its items array, original-resolution files versus feed thumbnails, shell and Python examples, rate limits and caveats. Updated: 2026-08-10 Resolve any public Pinterest pin — image, video or multi-page idea pin — with one GET request to the Pinterest downloader API. You get the stored file rather than the grid thumbnail, plus dimensions, duration and the description, for 1.5 credits. Instagram, TikTok, X, Facebook and RuTube answer on the same endpoint. - **Endpoint**: GET /fetch - **Credits**: 1.5 per pin - **Auth**: X-Api-Key header - **Media**: image pins, video pins, idea pins ## what a pin resolves to Pinterest runs through the same universal endpoint as the other non-YouTube platforms. Hand it a pin URL; it works out what the pin contains and answers with JSON. - Image pins — the stored file plus its pixel dimensions and the pin description. - Video pins — an MP4 with duration , and a poster frame in thumbnail_url . - Idea pins and carousels — multi-page pins arrive as type: "album" with an items array, one entry per page. - GIF-style pins — Pinterest stores most of these as video, so expect type: "video" . Three link shapes work: the canonical /pin// permalink, a pin.it short link from the mobile share sheet, and localised hosts such as ru.pinterest.com . Query strings are ignored, so the tracking noise the app appends is harmless. ## one request, one pin A GET with the pin in the url parameter and your key in a header. Nothing else. GET https://api.fastsaver.io/v1/fetch 1.5 credits ``` curl -G "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://pin.it/4kQvXcL9r" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "id": "1125968838649261847", "source": "pinterest.com", "type": "video", "download_url": "https://v1.pinimg.com/videos/mc/720p/...", "thumbnail_url": "https://i.pinimg.com/originals/...", "width": 720, "height": 1280, "duration": 21, "caption": "Walnut desk build — three-hour timelapse." } ``` --data-urlencode instead of gluing the link into the query string is a habit worth keeping: pin links carry their own parameters, and an unencoded & silently truncates the URL your server receives. An image pin is the same envelope with duration null: ``` { "ok": true, "id": "60446345121012345", "source": "pinterest.com", "type": "image", "download_url": "https://i.pinimg.com/originals/2f/8c/1d/....jpg", "thumbnail_url": "https://i.pinimg.com/236x/2f/8c/1d/....jpg", "width": 1536, "height": 2048, "duration": null, "caption": "Muted terracotta kitchen, brass fittings." } ``` A multi-page idea pin, and a carousel pin, use a different envelope. There is no top-level download_url , width or height — the pages live in an items array instead, and each entry carries its own type , download_url , thumbnail_url , width and height : ``` { "ok": true, "id": "3096293941226352", "source": "pinterest.com", "type": "album", "items": [ { "type": "image", "download_url": "https://i.pinimg.com/originals/a1/4e/90/....jpg", "thumbnail_url": "https://i.pinimg.com/originals/a1/4e/90/....jpg", "width": null, "height": null }, { "type": "video", "download_url": "https://v1.pinimg.com/videos/mc/720p/...", "thumbnail_url": "https://i.pinimg.com/originals/b7/22/05/....jpg", "width": 720, "height": 1280 } ], "thumbnail_url": "https://i.pinimg.com/originals/a1/4e/90/....jpg", "duration": null, "caption": "Five-step gouache landscape." } ``` Two details worth coding around. Image items report width and height as null — Pinterest does not publish per-page dimensions for them, so probe the file if you need them. And a one-page idea pin collapses to a plain image or video response rather than an album, so a branch that only handles "album" will miss it. ## the original file versus what the feed shows you Compare the two URLs in that image response. The thumbnail sits under a 236x path segment; the download sits under originals . Pinterest stores several derivatives of every upload and the browse grid serves the small ones — which is why right-clicking a pin in a feed hands you a 236-pixel-wide JPEG that looks fine as a thumbnail and terrible anywhere else. download_url points at the stored file, and width and height tell you what you are about to fetch. Read them: "original" means original upload , and a pin re-pinned from a compressed screenshot stays compressed. If you promise print-quality assets, gate on those dimensions. ## saving a list of pins Two shapes. The throwaway one — resolve, then pipe the bytes to disk: ``` curl -sG "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://www.pinterest.com/pin/1125968838649261847/" \ -H "X-Api-Key: fs_sk_•••••••••••" \ | jq -r '.download_url' \ | xargs -r curl -sL -o pin.mp4 ``` And the one you would deploy: a loop over a file of links, naming each output after the pin id so re-runs are idempotent and your storage keys stay stable. Note the album branch — reading download_url on an idea pin raises KeyError , because that key is only on single-media responses. ``` import pathlib, time, requests KEY = "fs_sk_•••••••••••" OUT = pathlib.Path("moodboard") OUT.mkdir(exist_ok=True) def write(name, media): dest = OUT / (name + (".mp4" if media["type"] == "video" else ".jpg")) dest.write_bytes(requests.get(media["download_url"], timeout=120).content) return dest def save_pin(link): r = requests.get( "https://api.fastsaver.io/v1/fetch", params={"url": link}, headers={"X-Api-Key": KEY}, timeout=60, ) data = r.json() if not data.get("ok"): print("skipped", link, data.get("detail")) return [] if data["type"] == "album": # idea pin or carousel: one file per page return [write(data["id"] + "-" + str(i), item) for i, item in enumerate(data["items"])] return [write(data["id"], data)] # image or video pin for link in pathlib.Path("pins.txt").read_text().split(): print(save_pin(link)) time.sleep(1) # stay inside your plan's per-minute limit ``` The sleep is not decoration. Ten requests per minute on the free tier disappears fast during a backfill, and a 429 costs a retry you could have avoided. ## what people build with it Pinterest traffic here skews towards ingest rather than one-off downloads: something else produced the link, and a job has to turn it into a file. ### Moodboard tools product A user pastes a pin; you store the file and its dimensions and lay out a real grid instead of hotlinking someone else's CDN. ### Design archives internal References that outlive the pin. Pins get deleted; your own copy, with the caption attached, does not. ### Content pipelines automation A worker pulls a link off a queue, gets JSON, pushes the file to object storage. No headless browser in the loop. ## what Pinterest keeps out of reach - Public pins only. Secret boards, and anything that asks you to log in, will not resolve. - One pin per call. No board, profile or search endpoint — you supply the links. - CDN URLs expire. Treat download_url as single-use: fetch the bytes in the same job, never store the link. - No dimensions inside an album. Idea-pin and carousel image items report width and height as null; only single-media responses carry real numbers. - Pacing is per plan. Free allows 10 requests a minute, Mega 900. A 429 says slow down, not that the pin is bad. - Pins disappear , and Pinterest changes its markup. We fix resolution behind the endpoint; a deleted pin nobody can fix. One more, and it is not a technicality: almost nothing on Pinterest was made by the account that pinned it. Downloading a file grants you no licence to republish it. Attribution, permission and copyright are yours to sort out. ## questions about the Pinterest API **Q: Does it work with pin.it short links?** A: Yes. Pass the pin.it link exactly as the share sheet gave it to you — the redirect is followed server-side. No need to resolve it first, and no need to strip the tracking parameters the app appends. **Q: Can I download Pinterest video pins and idea pins?** A: Yes. A video pin returns an MP4 in download_url with duration set. A multi-page idea pin comes back as type: "album" carrying an items array — one entry per page, each with its own type , download_url , thumbnail_url , width and height . An album has no top-level download_url , so branch on type before you read one. **Q: What image resolution does the Pinterest API return?** A: The stored file, not the grid thumbnail you see while browsing. Read width and height rather than assuming: the ceiling is whatever the pinner uploaded, and plenty of pins were re-pinned from an already-compressed copy. **Q: How many credits does one Pinterest request cost?** A: 1.5 credits, image or video, the same as TikTok, X and Facebook. The free tier's 1,000 credits cover roughly 660 pins — enough to test an ingest pipeline before you pick a plan. **Q: Can I pass a board URL or a profile instead of a single pin?** A: No. The endpoint resolves one pin per call. For a board, collect its pin URLs yourself and loop, with a delay that keeps you under your plan's per-minute limit. Secret boards need a login and are out of scope. ## Keep reading - All supported platforms — nine references - X (Twitter) video API — same endpoint, same price - Instagram downloader API — posts, reels, stories - General FAQ — keys, credits, legality ## Resolve a pin in the playground Paste any public pin link, hit send, and read the JSON before you commit to an integration. Open the playground Get a free API key --- # X (Twitter) video downloader API for MP4s, GIFs and images URL: https://fastsaverapi.com/twitter-video-downloader-api/ Summary: X (Twitter) media download endpoint: videos, GIFs and images from public posts via GET /fetch at 1.5 credits, covering x.com and twitter.com URL forms, why Twitter GIFs are silent MP4s, Python and shell examples, and an honest comparison with the official X API. Updated: 2026-08-10 The X (Twitter) video downloader API turns a public x.com or twitter.com post link into a playable MP4 with one GET request. You get a direct download URL, dimensions, duration and the post text back as JSON — no developer account, no OAuth, no app review. The same /fetch call also resolves five other platforms . - **Endpoint**: GET /fetch - **Credits**: 1.5 per post - **Auth**: X-Api-Key header - **Returns**: direct MP4 URL + metadata ## what this endpoint resolves X carries two kinds of moving image and treats them identically. A native video comes back as the uploaded MP4. So does a GIF: X stopped storing real GIFs years ago, and what loops in the timeline is a silent video. - Native video — the uploaded file, not a re-encode. - GIFs — a couple of seconds of MP4, no audio stream. - Still images — the full-size attachment, not the thumbnail. - Quoted and threaded posts — the link you pass decides whose media you get. A quote-post, the post it quotes and every reply in a thread are separate URLs with separate status IDs. Pass the outer link and you get the outer post's attachment — nothing at all, if the quoter added none. Copy the permalink of the post you actually want. ## the link formats you can pass Pass the URL exactly as you copied it — what matters is the status ID. - https://x.com//status/ — the canonical form. - https://twitter.com//status/ — still all over old databases. - https://x.com/i/web/status/ — the handle-less permalink. - Share links with ?t= and ?s=20 tracking parameters attached. Since share links carry query strings, URL-encode the url parameter in code. An unencoded &s=20 becomes a parameter of your request instead of part of the link, and the API sees a truncated URL. Normalise before you store anything. Rewrite the host to x.com , drop the query string, and pull the digits after /status/ — that number is your cache key. The id in the response is not: it is a verbatim echo of the URL you sent, so the same post arriving in four spellings becomes four cache entries and four paid calls. ## sending a status link The same shape as every other platform on /fetch . No POST body, no SDK, no session to keep warm. GET https://api.fastsaver.io/v1/fetch 1.5 credits ``` curl -G "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://x.com/SpaceX/status/1749286413298475123" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "id": "https://x.com/SpaceX/status/1749286413298475123", "source": "x.com", "type": "video", "download_url": "https://video.twimg.com/ext_tw_video/...", "thumbnail_url": "https://pbs.twimg.com/ext_tw_video_thumb/...", "width": 1280, "height": 720, "duration": 34, "caption": "Static fire test complete." } ``` Three fields shape your handler. type says whether you hold video or a still. duration is the cheapest tell that a "video" is really a GIF. source is an opaque platform label — do not compare it to a hardcoded hostname. One field shapes nothing: id is the URL you sent, echoed back, so it is an identifier in name only. Still images arrive without width , height or duration at all — read those with a default rather than a bracket lookup. Failures arrive as ok: false with a detail string, so branch on ok , not the HTTP status. ## gifs, and why yours is silent If you are building a reposting bot or an archive, this is the detail that bites. A Twitter GIF has no audio track, and pipelines that assume one exists — ffmpeg concat, some Telegram and Discord upload paths — fail or emit a broken file. Branch on it. In Telegram, send the silent MP4 as an animation and it plays inline, muted and looping, like the original. The API will not produce a real .gif for you: ``` ffmpeg -i clip.mp4 \ -vf "fps=15,scale=480:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" \ -loop 0 out.gif ``` Expect the result to be several times larger than the MP4. That is the format, not your settings. ## resolving and saving Two steps, separate on purpose: the API call authenticates with your key, the download does not. Never attach X-Api-Key to the CDN request — that host is not ours. ``` import re import httpx KEY = "fs_sk_•••••••••••" api = httpx.Client(headers={"X-Api-Key": KEY}, timeout=60) STATUS_ID = re.compile(r"/status/(\d+)") def cache_key(post_url: str) -> str: # the response's "id" is just the URL you sent - parse the real one m = STATUS_ID.search(post_url) if not m: raise ValueError(f"no status id in {post_url}") return m.group(1) def resolve(post_url: str) -> dict: r = api.get("https://api.fastsaver.io/v1/fetch", params={"url": post_url}) if r.status_code == 429: raise RuntimeError("rate limited - back off, then retry") r.raise_for_status() data = r.json() if not data.get("ok"): raise RuntimeError(data.get("detail", "could not resolve post")) return data def save(post_url: str, path: str) -> dict: media = resolve(post_url) # plain client: no API key header on the CDN request with httpx.stream("GET", media["download_url"], follow_redirects=True) as src: with open(path, "wb") as out: for chunk in src.iter_bytes(): out.write(chunk) return media info = save("https://x.com/SpaceX/status/1749286413298475123", "clip.mp4") print(cache_key(info["id"]), info["type"], info.get("width"), info.get("duration")) ``` From a shell: ``` curl -sG "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=$1" \ -H "X-Api-Key: $FASTSAVER_KEY" \ | jq -r 'select(.ok) | .download_url' \ | xargs -r curl -sL -o clip.mp4 ``` ## how this compares to the official X API Not competing products. X sells access to the platform; this sells one narrow capability — public post link in, playable file out. ### what you skip - No developer account application, no use-case description, no approval wait. - No OAuth flow, no app registration, no token refresh, no request signing. - No monthly tier you pay for in a month you barely called it — 1.5 credits per post. ### what you do not get - Posting, replying, liking, following. Nothing that writes. - Timelines, follower graphs, search, filtered streams, engagement metrics. - Anything behind an authenticated session, including your own protected content. Building an analytics dashboard or a scheduler? Use X's own API and do the OAuth work. Need the video out of a link a user pasted into your app? One GET here — and GET /balance reports your credits and per-minute limit for free. ## the X limits to design around - Public posts only. Protected accounts, sensitive-media interstitials and deleted posts return an error, not a file. - Download URLs expire. The link points at X's short-lived CDN — fetch the bytes in the same job and cache the status ID you parsed, never the CDN URL and never the response's id . - GIFs carry no audio. Handle zero-audio-stream video explicitly when you transcode or upload. - Rate limits are per plan — 10 requests per minute on Free, up to 900 on Mega. A 429 means slow down; the link is fine. - The platform keeps moving. X changes hosts and playback formats on its own schedule; we fix that behind the same endpoint. What you download is someone else's work. Copyright, X's terms and the rights of whoever posted it are yours to respect. ## questions about the X (Twitter) API **Q: Do x.com and twitter.com links both work?** A: Yes. Old twitter.com bookmarks, x.com shares and /i/web/status/… permalinks all resolve to the same media. One caveat if you are building a cache: the response's id is the URL you sent, echoed back verbatim — not the status ID. Three spellings of one post therefore give you three different id values. Parse the digits after /status/ yourself and key on those. **Q: Do I need an X developer account, an app or an OAuth token?** A: No. No application form, no app review, no token exchange — you send your X-Api-Key header and the post URL over HTTPS. What you get back is media resolution only: this endpoint cannot post, read a timeline or search. **Q: Why does a Twitter GIF download as an MP4?** A: Because it was never a GIF. X transcodes uploads to a short, silent MP4 and loops it in the client; the API hands you that file as stored. If you genuinely need a .gif , transcode it with ffmpeg after the download. **Q: Can it download video from protected or age-restricted posts?** A: No. Anything needing a logged-in session — protected accounts, sensitive-media interstitials, deleted posts — returns an error instead of a file. The endpoint resolves only what an anonymous visitor could already see. **Q: Can I pick a resolution, like 720p or 480p?** A: Not here. GET /fetch takes one parameter, url , and returns the file the platform serves — with width and height on video responses so you know what you got. Format selection exists only on the YouTube endpoints. ## Keep reading - Facebook video downloader API — same endpoint, same shape - TikTok downloader API — also 1.5 credits - All supported platforms — nine references - Frequently asked questions — keys, limits, legality ## Resolve a post link right now Paste an x.com URL into the playground and read the JSON before committing to anything. Run it in the playground Create a free API key --- # Facebook video downloader API for reels, Watch posts and share links URL: https://fastsaverapi.com/facebook-video-downloader-api/ Summary: Facebook video and reels download endpoint: GET /fetch at 1.5 credits, routing by host (facebook.com and its subdomains, plus fb.com — fb.watch is rejected with 400 Invalid URL and must be expanded with a redirect-following HEAD request first), accepted path shapes (/watch/?v=, /reel/, /share/v/, permalink.php), the fields that differ from the shared /fetch envelope (width and height are always null, and id is the URL you sent rather than a video ID), Python and Node examples, public-only boundary and platform-churn caveats. Updated: 2026-08-10 The Facebook video downloader API resolves a public video or reel to a direct fbcdn.net URL with one GET request. Every path shape on the platform's own hosts — /watch/?v= , /reel/ , /share/v/ , old permalink.php URLs — goes into the same parameter. The one link you must expand yourself is an fb.watch short link. Facebook is also the platform that rearranges its markup most often, and keeping up with that is the part you are handing over. - **Endpoint**: GET /fetch - **Credits**: 1.5 per video or reel - **Auth**: X-Api-Key header - **Accepts**: facebook.com & fb.com hosts · /watch/ · /reel/ · /share/v/ · permalink.php ## the many shapes of a Facebook link Facebook has accumulated more URL formats than every other platform on this site combined — two decades of redesigns and a share sheet that picks a different one depending on where you tapped. Path shape does not matter: anything on the right host goes into the same parameter exactly as the user pasted it. - facebook.com/watch/?v=1234567890 — the Watch player, with or without the trailing slash. - facebook.com/reel/1234567890 — Reels, Facebook's answer to short vertical video. - facebook.com/share/v/AbCdEf1234/ — the newer share format now used across the apps. - facebook.com/pagename/videos/1234567890/ — a video posted to a page or profile. - facebook.com/permalink.php?story_fbid=…&id=… — the legacy permalink, still handed out by notification emails. What does matter is the host. Routing happens on it before anything looks at the path, and the match is exact-or-subdomain against facebook.com and fb.com . So m.facebook.com , web.facebook.com and the locale prefixes all resolve — they end in .facebook.com — and so does a bare fb.com link. fb.watch does not. It is a separate host, it matches neither entry, and a request carrying one comes straight back as 400 with {"ok": false, "detail": "Invalid URL"} — the redirect is never followed for you. Resolve it on your side first: one HEAD request with redirects enabled returns the canonical facebook.com URL, and that is the string you send. It costs you a round-trip and no credits, and it is the single most common reason a Facebook integration fails on links copied from a phone. One more trap worth naming. Two of those formats carry their own query string. Drop permalink.php?story_fbid=123&id=456 into a request unencoded and &id= becomes a parameter of your call rather than part of Facebook's link, so the API receives a truncated URL. Percent-encode the value; every HTTP client has a helper for it. ## one GET, JSON back No SDK, no cookie jar, no headless Chrome sitting in your container image. A query parameter and a header. GET https://api.fastsaver.io/v1/fetch 1.5 credits ``` curl -G "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://www.facebook.com/reel/1234567890" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "id": "https://www.facebook.com/reel/1234567890", "source": "facebook.com", "type": "video", "download_url": "https://video-waw2-1.xx.fbcdn.net/o1/v/...", "thumbnail_url": "https://scontent-waw2-1.xx.fbcdn.net/v/...", "width": null, "height": null, "duration": 34, "caption": "Sunday league, last-minute equaliser." } ``` The -G plus --data-urlencode pairing is the least error-prone way to send a link that carries its own ? and & . ## the fields Facebook actually fills in It is the same envelope every /fetch platform returns, which is the point when one worker handles links from several sources: ok , source , thumbnail_url , duration and caption behave exactly as they do in the full field reference on the Instagram page . Four other fields behave differently enough here to be worth spelling out. | Field | Type | Notes | | --- | --- | --- | | id | string | Not an ID: the video branch echoes back the URL you sent, character for character. Two link shapes for the same clip give two different id values, so it is no use as a cache key. | | type | string | video for videos and reels alike — there is no separate reel type to branch on. | | download_url | string | Signed fbcdn.net URL. Time-limited — treat it as a one-shot ticket. | | width / height | null | Always null on a Facebook video. The keys are in the payload; the values never are. | That last row is the one that catches people. Reels are vertical and Watch uploads are usually landscape, but the response will not tell you which — retrying does not turn those nulls into pixels. If your UI needs a width, probe the file once you have the bytes ( ffprobe , or the browser's own metadata event) rather than reserving space from the JSON. ## resolving and saving in one pass Because the CDN URL expires, the useful unit of work is resolve-then-store, not resolve-then- queue. Streaming straight to disk keeps memory flat even on a long video. The three-line expand helper is the fb.watch fix from above, and it is worth having in the path permanently — phone-copied links arrive in that shape constantly. ``` import shutil import requests API = "https://api.fastsaver.io/v1/fetch" HEAD = {"X-Api-Key": "fs_sk_•••••••••••"} def expand(link: str) -> str: # fb.watch is not a routed host — follow it to facebook.com before sending if "fb.watch/" in link: return requests.head(link, allow_redirects=True, timeout=30).url return link def save(link: str, path: str) -> dict: meta = requests.get(API, params={"url": expand(link)}, headers=HEAD, timeout=60).json() if not meta.get("ok"): raise RuntimeError(meta.get("detail", "could not resolve")) # signed and short-lived: pull the bytes now, not in a later job with requests.get(meta["download_url"], stream=True, timeout=300) as r: r.raise_for_status() with open(path, "wb") as f: shutil.copyfileobj(r.raw, f) return meta info = save("https://fb.watch/AbCdEf1234/", "clip.mp4") print(info["source"], info["duration"], "seconds") ``` In Node, build the query with URL rather than string concatenation and the encoding problem disappears. ``` const endpoint = new URL('https://api.fastsaver.io/v1/fetch'); endpoint.searchParams.set('url', link); // already expanded; encodes ? and & for you const res = await fetch(endpoint, { headers: { 'X-Api-Key': process.env.FASTSAVER_KEY }, }); if (res.status === 429) { // plan rate limit reached — back off, do not retry immediately throw new Error('rate limited'); } const data = await res.json(); if (!data.ok) throw new Error(data.detail ?? 'could not resolve'); // data.width and data.height are null on Facebook — do not lay out from them // data.id is the URL you sent echoed back, not a video ID console.log(data.duration, data.download_url); ``` ## public means public The API sees what a logged-out visitor sees. That boundary is deliberate and not configurable. These never resolve: - Posts limited to friends, or to a custom audience. - Anything inside a private or closed group, including videos a member reshared there. - Videos on pages that are unpublished, geo-blocked, or restricted to an age-verified audience. - Content that has been deleted — the ID survives in links long after the file does not. Each returns ok: false with a reason, not a half-written file. Build the error path around that and most Facebook edge cases are already handled. ## where Facebook links break - Facebook changes its markup more often than anything else we support. That churn is absorbed on our side and your call signature never moves, though a specific link shape can lag for a few hours after a change lands. - CDN URLs expire. Signed fbcdn.net links die on their own schedule. Store the file, or store the post URL and re-resolve — never the download URL. - A live broadcast has no finished file. Wait for the replay to be published before pointing anything at it. - Throughput is capped per plan. Ten requests a minute on the free tier, 900 on the largest. A bulk job needs a queue with backoff, not a tight loop. - Credits pay for the work, not the outcome. A resolved link costs 1.5 credits whether or not you keep the file, so deduplicate before you call. Key that cache on a URL you normalised yourself — expanded, lowercased host, tracking parameters stripped — because the id in the response is just your own URL handed back and will not collapse two shapes of the same clip into one entry. What you do with the media is on you. A public URL is not a licence: the uploader holds copyright and Facebook's terms still apply to redistribution. ## questions about the Facebook API **Q: Can the API download private or friends-only Facebook videos?** A: No. Only content a signed-out visitor can open is reachable — friends-only posts, private-group videos and unpublished pages all return an error. Quickest test: open the link in a private browser window. If it plays without a login prompt, the API can resolve it. **Q: Do I need to expand an fb.watch link before sending it?** A: Yes, and this one bites people. Requests are routed by host, and only facebook.com (with its subdomains) and fb.com are matched. fb.watch is neither, so it never reaches the Facebook resolver — you get 400 with {"ok": false, "detail": "Invalid URL"} . Follow the short link yourself first: a HEAD request with redirects enabled lands on the canonical facebook.com URL, and that is what you send. /share/v/ links need no such step — they are already on facebook.com . **Q: How much does one Facebook video cost?** A: 1.5 credits per resolved link, videos and reels alike. The free tier ships with 1,000 credits, which is a little over 660 videos before any payment is involved. Checking your remaining balance costs nothing. **Q: Why did a Facebook link that worked last week stop working?** A: Usually the post changed — deleted, made friends-only, or moved into a group. If it is still public and still failing, Facebook has probably shipped a markup change. Those are patched centrally; nothing in your integration changes. **Q: Why are width and height null on every Facebook response?** A: Because Facebook does not expose them to us. The two keys are always in the payload and always null on a video, so treat them as unavailable rather than as something a retry will fix. If you need the dimensions, read them from the file after you have downloaded it. **Q: Does the endpoint return the video file or a link to it?** A: A link. The response is JSON with a download_url pointing at Facebook's own CDN, plus metadata. Nothing is proxied through us, so transfers run at CDN speed — but the URL is signed and expires, so fetch the bytes in the same job. ## Keep reading - X (Twitter) video API — same endpoint, same envelope - Instagram downloader API — reels, posts, stories - All supported platforms — nine references - Keys, limits and legality — general FAQ ## Point it at a real Facebook link The playground runs the call in the browser and shows you the raw JSON before you commit to any code. Open the playground Get a free API key --- # RuTube downloader API without a proxy or a VPN URL: https://fastsaverapi.com/rutube-downloader-api/ Summary: RuTube video download reference: resolve public rutube.ru links to a direct media URL plus metadata with GET /fetch at 3 credits, or 0.1 credits when the resolve fails — request shape, the RuTube-specific response fields (type is video or image, width and height are null on a video and absent on an image), Python and shell examples, geo handling, URL expiry, rate limits and legal caveats. Updated: 2026-08-10 The RuTube downloader API resolves any public rutube.ru video with a single GET request — a direct video URL, thumbnail, duration and caption as JSON, for 3 credits a call. The geo-sensitive part of reaching RuTube happens on our infrastructure, so your workers run wherever you like. It is the same call you already make for Instagram or TikTok. - **Endpoint**: GET /fetch - **Credits**: 3 per link, 0.1 if it fails - **Auth**: X-Api-Key header - **Returns**: direct media URL + metadata ## who actually needs this RuTube is where much of the Russian-speaking internet watches video, and it is nearly invisible in tooling built elsewhere. If your product has users across Russia, Belarus, Kazakhstan or the wider CIS, RuTube links will arrive whether you planned for them or not. - Telegram bots and chat apps — users paste whatever they were watching; a bot that shrugs looks broken. - Aggregators and schedulers — mirroring a creator's uploads breaks when one platform is unreachable. - Archival and monitoring tools — research and newsrooms need the file, not a page that may vanish. - Localised products — for many apps RuTube is the platform their audience uses, not a long-tail extra. None of that is exotic — the same product shapes people build on Instagram or TikTok, for an audience that happens to be elsewhere. ## why RuTube is awkward to fetch yourself RuTube does not hand you an MP4. Video arrives as a segmented HLS stream, and access to the playlist is geo-sensitive: the same code run from a US or EU cloud region gets a different answer, sometimes none at all. That is an infrastructure problem, not a parsing problem — which is why most general-purpose downloaders list every platform except this one. We absorb it: egress, retries, segment handling and the churn when the player changes. What crosses the wire to you is a plain HTTPS URL you can GET from anywhere. One caveat — do not hardcode or pattern-match that URL's hostname. It is not guaranteed to be the same on the next request. ## one GET, one link No SDK, no body, no per-platform branch. You pass the video link and read the JSON. GET https://api.fastsaver.io/v1/fetch 3 credits ``` curl "https://api.fastsaver.io/v1/fetch?url=https%3A%2F%2Frutube.ru%2Fvideo%2Fd1c0a7f0b4e94a2c8f5b6d3e1a7c9b02%2F" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "id": "d1c0a7f0b4e94a2c8f5b6d3e1a7c9b02", "source": "rutube.ru", "type": "video", "download_url": "https://...", "thumbnail_url": "https://...", "width": null, "height": null, "duration": 843, "caption": "Тест-драйв нового кроссовера" } ``` Those two nulls are not a quirk of this example: a RuTube video response never carries pixel dimensions. Everything else is populated on a normal public video — and a RuTube photo post answers with a shorter object than this one, which the field notes below spell out. Percent-encode the url value, as above. RuTube share links often carry a tracking query string, and an unescaped & reads as the start of your next parameter — the request still returns 200, just for the wrong link. ## the four fields that behave differently here The envelope is the shared one: ok , source , thumbnail_url , duration and caption mean exactly what they mean on every other platform, and the Instagram downloader API page documents them row by row. Rather than repeat that table, here is only what a RuTube link does differently. | Field | Type | On RuTube | | --- | --- | --- | | id | string | RuTube's own 32-character hex identifier — the one in the /video/…/ path. Use it as the primary key in your cache. | | type | string | video on a normal upload, image on a RuTube photo post. Never album — there is no items array to walk, so the branch you wrote for Instagram carousels never fires here — but do not hardcode video either. | | download_url | string | A plain HTTPS URL you can GET from any region — not a RuTube CDN link. Time-limited, and the hostname can change between calls. On an image response it points at the picture itself. | | width / height | null | Always null on a video, and absent altogether on an image. RuTube never reports pixel dimensions, so size a player from the file or the poster image instead. | That image case is uncommon, but it is the one shape that changes which keys exist rather than which values they hold. A photo post carries download_url , thumbnail_url — the same URL, there is no separate poster — and caption , and no width , height or duration at all. Not null: missing. Code that reads meta["duration"] straight out of the object raises on it. ``` { "ok": true, "id": "c4f21a8e7b3d40559ae6182fbb7c0d31", "source": "rutube.ru", "type": "image", "download_url": "https://...", "thumbnail_url": "https://...", "caption": "Кадр со съёмок" } ``` Read everything except ok and type defensively. Branch on type first, then let coverage vary upload to upload — a missing caption is normal, not a failure. Use .get() with a default rather than indexing straight into the object. ## adding RuTube to code you already wrote There is no RuTube client, no RuTube-shaped response, no second code path. If your service already calls /fetch , support here is a hostname in a set. Below is a complete resolve-and-save worker; the RuTube-specific part is one line. ``` import requests from urllib.parse import urlparse FETCH = "https://api.fastsaver.io/v1/fetch" HEADERS = {"X-Api-Key": "fs_sk_•••••••••••"} SUPPORTED = { "instagram.com", "tiktok.com", "pinterest.com", "x.com", "facebook.com", "rutube.ru", # <- the entire change } def host_of(link): return urlparse(link).netloc.removeprefix("www.").lower() def save(link, path): host = host_of(link) if host not in SUPPORTED: raise ValueError("unsupported host: " + host) meta = requests.get(FETCH, params={"url": link}, headers=HEADERS, timeout=90).json() if not meta.get("ok"): raise RuntimeError(meta.get("detail", "fetch failed")) # Stream to disk — a 40-minute upload will not fit comfortably in memory. with requests.get(meta["download_url"], stream=True, timeout=300) as r: r.raise_for_status() with open(path, "wb") as f: for chunk in r.iter_content(1 << 16): f.write(chunk) return meta save("https://rutube.ru/video/d1c0a7f0b4e94a2c8f5b6d3e1a7c9b02/", "clip.mp4") ``` Or, if you just want the file locally: ``` URL=$(curl -s -G "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://rutube.ru/video/d1c0a7f0b4e94a2c8f5b6d3e1a7c9b02/" \ -H "X-Api-Key: $FASTSAVER_KEY" | jq -r '.download_url') curl -L -o clip.mp4 "$URL" ``` curl -G --data-urlencode does the escaping for you — the most common cause of a confusing empty result, gone. ## what RuTube will not give you - Public videos only. Login walls, age gates, paid content and rights-holder blocks return an error. No parameter unlocks them. - No dimensions. width and height come back null on every RuTube video and are absent from an image response. Neither shape tells you the pixel size; if your UI needs it, read it off the downloaded file. - The download URL expires. Resolve and transfer in the same job. Re-resolving costs another 3 credits and still beats a dead link. - A failure is not free. Private, deleted or malformed links charge 0.1 credits each — 1/30th of a successful resolve, but budget for it if you replay a queue of links that never worked. - Files are large. RuTube hosts long-form video; forty minutes at 1080p runs to hundreds of megabytes. Stream to storage, set generous timeouts. - Throughput is capped per plan — 10 requests per minute on Free, 900 on Mega. On RuTube you will usually saturate your own transfer workers long before you see a 429 . - Platforms move. When RuTube changes its player, we ship the fix behind the same endpoint. Your integration stays put. What you do with the file is your responsibility. A download is not a licence — respect the uploader's rights, the platform's terms and your local copyright law. Archiving for research and republishing someone's video are different acts. ## questions about the RuTube API **Q: Do I need a Russian proxy or a VPN to download RuTube videos?** A: No — that is the part we take on. Reaching RuTube is a networking problem: where a request originates changes what the platform returns. Your worker can sit in Frankfurt or Ohio and get the same JSON. **Q: Does this need a RuTube account, token or cookies?** A: No. The only credential is your own X-Api-Key header. No OAuth dance, no session to keep warm, nothing to renew when RuTube rotates something. **Q: What does one RuTube video cost?** A: 3 credits — twice what a TikTok, Pinterest, X or Facebook link costs, and the difference pays for the geo-sensitive fetch described above. On Pro that is roughly 33,000 videos a month; the free tier covers about 333. Failures are cheap but not free: every failed resolve — private video, dead link, malformed URL — charges a flat 0.1 credits, so a retry loop over links that will never work still bills you. Checking /balance is free. **Q: How long is the download_url usable?** A: Long enough to download, not long enough to store. Treat it as a one-shot handle: resolve, transfer, discard. Need the video next week? Call /fetch again — cheaper than debugging a link that died in your database. **Q: Can I download private, restricted or paid RuTube videos?** A: No. The API resolves what an anonymous visitor can watch. Anything behind a login, an age gate, a purchase or a rights-holder block returns an error, and no parameter changes that. ## Keep reading - Every platform /fetch covers — one endpoint, nine references - Instagram downloader API — the shared response envelope - X (Twitter) video API — same call, 1.5 credits - Keys, credits and rate limits — general FAQ ## Resolve a RuTube link right now Paste a rutube.ru URL into the playground and look at the JSON before you commit to anything. Open the playground Get a free API key --- # Telegram music bot that never downloads a file URL: https://fastsaverapi.com/guides/telegram-music-bot/ Summary: Complete aiogram 3 tutorial for a Telegram music bot: GET /youtube/search (2 credits) fills an inline keyboard, POST /youtube/audio/tg-bot returns a bot-scoped file_id sent with answer_audio and costs 7 credits when the track is already cached but 15 when it has to be fetched first, plus a SQLite file_id cache, handling for empty results, 401, 429, stale references, failed resolves (a flat 0.1 credits) and out-of-credits (HTTP 400 with detail "Insufficient credits. Please top up to your account." — the API never returns 402), and the credit maths for the free and $9 Pro plans. Updated: 2026-08-10 A Telegram music bot in three files: the user types a song name, your bot searches YouTube Music, and a tap sends the track as a Telegram file_id — no download, no upload, no ffmpeg. Full aiogram 3 code, the error paths included, and an honest count of what each song costs in credits . - **Stack**: Python 3.11+ · aiogram 3 · httpx - **Endpoints**: GET /youtube/search · POST /youtube/audio/tg-bot - **Cost**: 2 to search · 7 per track cached, 15 on a miss - **Bytes through your server**: none ## what you are building The bot has two states. A user sends text, you search YouTube Music and reply with an inline keyboard of matches. The user taps a row, you turn that row's video ID into a Telegram file_id and send it as audio. Note what is missing: no MP3 on disk, no temp directory, no multipart upload leaving your process. Your bot passes a string where a file would go and Telegram serves the bytes. Two API calls per song — nine credits when we already hold the track, seventeen the first time anyone asks for it — and three files: a client, a cache, the handlers. ## setup You need a bot token from BotFather and a FastSaverAPI key. Keep both in the environment; the token alone lets anyone impersonate your bot. ``` python -m venv .venv && source .venv/bin/activate pip install "aiogram>=3.7" httpx export BOT_TOKEN="123456789:AA..." # from @BotFather export FASTSAVER_KEY="fs_sk_•••••••••••" # from your dashboard ``` Pin aiogram 3 deliberately: snippets written for 2.x will not run here, and from 3.7 defaults such as parse_mode moved onto DefaultBotProperties . Pick the bot username now and treat it as configuration — a file_id is issued for one bot and rejected by every other. The Telegram bot media API page explains what a file_id actually is and how the scoping rule works; this guide just obeys it. ## step one — find the track GET /youtube/search queries YouTube Music rather than the main site, so "tame impala" returns tracks instead of reaction videos. Both query and page are required. GET https://api.fastsaver.io/v1/youtube/search 2 credits · page 1–3 ``` curl "https://api.fastsaver.io/v1/youtube/search?query=tame%20impala&page=1" \ -H "X-Api-Key: $FASTSAVER_KEY" ``` ``` { "ok": true, "page": 1, "results": [ { "video_id": "5K2ADZ7gzMc", "title": "Tame Impala - The Less I Know The Better", "duration": "3:38", "thumbnail": "https://i.ytimg.com/vi/5K2ADZ7gzMc/mqdefault.jpg", "thumbnail_max": "https://i.ytimg.com/vi/5K2ADZ7gzMc/maxresdefault.jpg" } ] } ``` Two details matter downstream. duration is a display string, not seconds. And video_id is the only field the next call needs; the rest exists so you can build a readable keyboard without a second lookup. The full field reference — and the other three YouTube endpoints — lives on the YouTube downloader API page. ## step two — the API client Wrap both endpoints once, in a class that turns HTTP failures into exceptions your handlers can branch on — and treats ok , not the status code, as the success flag. ``` # fastsaver.py import httpx # Running out of credits is a plain HTTP 400 — there is no 402 anywhere in this # API — and 400 is also what an unresolvable link returns, so the detail string # is the only thing that tells the two apart. INSUFFICIENT = "Insufficient credits" class ApiError(RuntimeError): """The API refused the request.""" class OutOfCredits(ApiError): pass class RateLimited(ApiError): pass class FastSaver: def __init__(self, key: str, bot_username: str) -> None: self.bot_username = bot_username self._http = httpx.AsyncClient( base_url="https://api.fastsaver.io/v1", headers={"X-Api-Key": key}, timeout=httpx.Timeout(120.0, connect=10.0), ) async def _call(self, method: str, path: str, **kw) -> dict: r = await self._http.request(method, path, **kw) if r.status_code == 401: raise ApiError("api key missing or wrong") if r.status_code == 429: raise RateLimited("plan rate limit reached") data = r.json() if not data.get("ok"): detail = data.get("detail") or f"http {r.status_code}" if INSUFFICIENT in detail: # 400, not 402 raise OutOfCredits(detail) raise ApiError(detail) return data async def search(self, query: str, page: int = 1) -> list[dict]: data = await self._call( "GET", "/youtube/search", params={"query": query, "page": page}, ) return data["results"] async def audio_file_id(self, video_id: str) -> str: data = await self._call( "POST", "/youtube/audio/tg-bot", json={"video_id": video_id, "bot_username": self.bot_username}, ) return data["file_id"] async def balance(self) -> dict: return await self._call("GET", "/balance") ``` The status codes are worth reading twice, because the obvious guess is wrong. An exhausted balance does not come back as 402 Payment Required ; it comes back as 400 with {"ok": false, "detail": "Insufficient credits. Please top up to your account."} . Branch on 402 and the branch is simply never taken — your users get whatever generic message you kept for the unknown case, and you find out you are broke from a support message rather than a log line. Match on the detail text instead. 401 and 429 really are their own statuses, so those two stay where they are. The 120-second read timeout is not padding: most tracks answer immediately from our cache, but one nobody has asked for before is fetched on demand, and that is measured in seconds. It is also billed as a full download — 15 credits rather than 7 — so a miss costs you on both axes. The connect timeout stays short so a dead network fails fast. ## step three — the handlers Two handlers: one for text, one for taps. callback_data is capped at 64 bytes, so only the eleven-character video ID travels in it; titles live in a dict keyed by the same ID. ``` # bot.py import asyncio, logging, os from aiogram import Bot, Dispatcher, F from aiogram.exceptions import TelegramBadRequest from aiogram.types import CallbackQuery, Message from aiogram.utils.keyboard import InlineKeyboardBuilder from cache import drop_file_id, get_file_id, put_file_id from fastsaver import ApiError, FastSaver, OutOfCredits, RateLimited BOT_USERNAME = "@example_music_bot" bot = Bot(os.environ["BOT_TOKEN"]) dp = Dispatcher() api = FastSaver(os.environ["FASTSAVER_KEY"], BOT_USERNAME) titles: dict[str, str] = {} def excuse(exc: ApiError) -> str: if isinstance(exc, OutOfCredits): return "The bot is out of credits. Ping the owner." if isinstance(exc, RateLimited): return "Busy right now — try again in a minute." return "Something broke on our side. Try again." @dp.message(F.text & ~F.text.startswith("/")) async def on_text(msg: Message) -> None: query = msg.text.strip()[:100] try: results = await api.search(query) except ApiError as exc: logging.warning("search failed for %r: %s", query, exc) await msg.answer(excuse(exc)) return if not results: await msg.answer("No tracks matched that. Try the artist name too.") return kb = InlineKeyboardBuilder() for track in results[:8]: titles[track["video_id"]] = track["title"] kb.button( text=f"{track['title']} · {track['duration']}", callback_data=f"a:{track['video_id']}", ) kb.adjust(1) await msg.answer("Pick a track:", reply_markup=kb.as_markup()) @dp.callback_query(F.data.startswith("a:")) async def on_pick(cb: CallbackQuery) -> None: await cb.answer("Fetching…") # ack now, or the button spins forever video_id = cb.data[2:] file_id = get_file_id(video_id, BOT_USERNAME) if file_id is None: try: file_id = await api.audio_file_id(video_id) except ApiError as exc: logging.warning("resolve failed for %s: %s", video_id, exc) await cb.message.answer(excuse(exc)) return put_file_id(video_id, BOT_USERNAME, file_id) try: await cb.message.answer_audio(file_id, title=titles.get(video_id)) except TelegramBadRequest: drop_file_id(video_id, BOT_USERNAME) await cb.message.answer("That reference went stale. Tap it again.") async def main() -> None: logging.basicConfig(level=logging.INFO) acct = await api.balance() # free call, never spends credits logging.info("plan=%s credits=%s rpm=%s", acct["plan"], acct["credits"], acct["rpm_limit"]) await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main()) ``` The first line of on_pick is the one people leave out. Telegram wants a prompt answer to a callback query; resolve the track first, let a cold fetch take six seconds, and the user watches a spinner and taps again — you have now paid for one song twice, 15 credits and then 7. excuse() only has an out-of-credits message to show because the client bothered to read the detail string. Classify in one place, phrase in another: handlers should ask "which exception is this", never "which status code was that". The startup call to /balance is free and puts your plan, credits and rate limit in the first log line — the cheapest way to learn that this container has the wrong key. ## step four — remember the file_id The delivery endpoint costs 7 credits when we already hold the track and 15 when we have to fetch it first. Sending a file_id you already hold costs nothing at all. So store them, keyed by video ID and bot username. ``` # cache.py import sqlite3 db = sqlite3.connect("tracks.db", check_same_thread=False) db.execute(""" CREATE TABLE IF NOT EXISTS file_ids ( video_id TEXT NOT NULL, bot TEXT NOT NULL, file_id TEXT NOT NULL, PRIMARY KEY (video_id, bot) ) """) db.commit() def get_file_id(video_id: str, bot: str) -> str | None: row = db.execute( "SELECT file_id FROM file_ids WHERE video_id = ? AND bot = ?", (video_id, bot), ).fetchone() return row[0] if row else None def put_file_id(video_id: str, bot: str, file_id: str) -> None: db.execute("INSERT OR REPLACE INTO file_ids VALUES (?, ?, ?)", (video_id, bot, file_id)) db.commit() def drop_file_id(video_id: str, bot: str) -> None: db.execute("DELETE FROM file_ids WHERE video_id = ? AND bot = ?", (video_id, bot)) db.commit() ``` Yes, sqlite3 blocks the event loop. At a few hundred taps an hour the queries are microseconds; when that stops being true, move the table behind an async driver and change three functions. Do not start there. The composite key is not decoration. Migrate to a second bot and every stored string is useless to it — with the bot in the key it just misses the cache and re-resolves. ## the paths that are not the happy one Seven routine failures. Catch every one at the handler — a single bad video ID should never stop the polling loop. Two of them share HTTP 400, so read the detail , not the status. | Failure | What to do | | --- | --- | | Empty results | Nothing matched and the 2 credits are spent. Ask for the artist name; the same text will fail again. | | 400 · Insufficient credits. Please top up to your account. | Out of credits — this exact detail string, on a 400. There is no 402 to catch. Nothing works until you top up, so watch /balance from a health check; that call is free. | | 400 · any other detail | The track is private, region-locked, age-gated or gone. A failed resolve is billed at a flat 0.1 credits, so tell the user and move on — a retry loop buys the same answer at 0.1 a go. | | 429 | Past your plan's per-minute limit: 10 Free, 60 Pro, 900 Mega. Back off, then queue the resolve calls. | | 401 | The key never reached us — usually an unset environment variable. | | TelegramBadRequest | A stale file_id. Drop the row, let the next tap re-resolve. | | TelegramRetryAfter | Telegram's own flood control. Wait the seconds it gives you. | That first 400 is the one worth wiring into your alerting. It is the only failure on the list that will not fix itself, and because it arrives with the same status as a dead link, a client that only looks at r.status_code reports "that track is unavailable" for every song in the catalogue. ## what it actually costs Three prices, not two: 2 credits to search, then 7 to turn a video ID into a file_id if we already hold that track — and 15 if we do not, because the first request for a track pays for the download. Resending something already in your own table is free. | Interaction | API calls | Credits | | --- | --- | --- | | Search, tap a track we already hold | 2 | 9 | | Search, tap a track nobody has pulled yet | 2 | 17 | | Delivery only — id from charts or a deep link | 1 | 7 or 15 | | A track already in your cache table | 0 | 0 | | A resolve that fails | 1 | 0.1 | You cannot tell which price a tap will pay until it has been paid, and our cache is shared, so the odds improve as more people ask for the same music. Plan with a miss rate rather than a single number: one tap in five landing on something new puts the average flow at 10.6 credits. The free tier's 1,000 credits therefore buy 111 search-and-send flows if every track is already cached, 58 if none are, and about 94 at that one-in-five rate. Deliveries on their own, when the video ID comes from a chart or a deep link: 142 cached, 66 uncached. Enough to test properly; not enough to launch. The $9 Pro plan's 100,000 credits a month buy roughly 11,100 flows all-cached, 9,400 at a one-in-five miss rate, and 5,880 in the pathological case where every track is new — 81 cents, 95 cents and $1.53 per thousand songs. Credits bind long before the rate limit does: 9,400 flows a month averages thirteen an hour, nowhere near Pro's 60 requests a minute. That spread is the argument for the table in step four. Your own file_id cache does not just shave 7 credits off a repeat send — it takes the second and every later delivery of a track to zero, which is what pulls a real bot's average toward the good end of that range. Music requests are heavily repetitive; a few hundred rows of SQLite absorb most of them. ## where a Telegram music bot hits its ceiling - Public content only. If a track needs a login, a membership or a different country to play, the ID will not resolve. - A file_id belongs to one bot. Not portable to a second bot, not to a user account. - A cache miss costs more, not just longer. 15 credits instead of 7, on a delay you cannot predict — budget for both. - Two rate limits, two owners. Ours is per plan, per minute. Telegram polices your bot separately. - YouTube changes; your bot does not. Breakage is repaired behind the endpoint, so a bot deployed months ago keeps answering without a redeploy. And the part no library handles: a bot with an audience distributing recorded music is publishing it. Whether you may is between you, the rights holders and Telegram's terms. ## questions about building the bot **Q: Does the bot need yt-dlp or ffmpeg installed?** A: No. Nothing is decoded or converted on your machine — aiogram and an HTTP client are the whole dependency list. **Q: How many songs can the bot deliver on the free plan?** A: Between 58 and 111, and where you land depends on our cache. A flow is 2 credits for /youtube/search plus 7 for /youtube/audio/tg-bot when we already hold the track, or 15 when we have to fetch it first — 9 or 17 credits against the 1,000 free ones. Budget for about 94 if you assume one tap in five is a track nobody has pulled before. Skip the search and it is 142 cached, 66 uncached. **Q: Which status code means the bot has run out of credits?** A: HTTP 400 , with detail set to "Insufficient credits. Please top up to your account." There is no 402 in this API, so a client that branches on 402 silently never sees the case. Match the detail string, and remember 400 also covers a link that cannot be resolved — the status alone does not tell you which happened. **Q: Why does the button keep spinning after a user taps a track?** A: The callback query was not answered in time. Resolving an uncached track takes seconds, so call cb.answer() on the first line of the handler and send the audio afterwards as a normal message. **Q: Can I use inline mode instead of an inline keyboard?** A: Yes — InlineQueryResultCachedAudio takes a file_id . The catch is arithmetic: you need one per result shown, so ten results for a single keystroke is 70 credits if every track is cached on our side and 150 if none of them are. Serve inline results from your cache table only. **Q: How many search results can one query return?** A: Ten per page, and page accepts 1, 2 or 3 — thirty at most, 2 credits per page. A keyboard longer than eight rows is unreadable on a phone, so page 1 is usually the whole feature. ## Keep reading - Telegram bot media API — the endpoint reference - YouTube downloader API — search, info, download - Shazam API — identify a forwarded voice note - YouTube to MP3 over HTTP — when you need the file ## Build it this evening 1,000 free credits is 58 to 111 songs, depending on the cache — more than enough to get the bot working end to end. Get an API key Try the endpoints --- # downloading Instagram reels in Python, streamed and batched URL: https://fastsaverapi.com/guides/download-instagram-reels-python/ Summary: Python tutorial for downloading Instagram reels with requests: GET /fetch to resolve a link, streamed chunked writes with a .part rename, urllib3 retries and split connect/read timeouts, reading the JSON body instead of the HTTP status because an out-of-credits call answers 400 rather than 402, a rate-paced batch runner keyed on the shortcode, why the signed CDN download_url expires, and an honest comparison with instaloader and yt-dlp. Updated: 2026-08-10 Downloading Instagram reels in Python is two HTTP requests: resolve the link to a media URL, then pull the bytes. This guide builds the downloader with requests — naive first, then streamed to disk, then with retries and timeouts, then as a batch runner over a list of links. It also covers why that media URL expires, and why that single fact decides how you design a queue. - **Language**: Python 3.9+, requests only - **Endpoint**: GET /fetch · 1.5 credits per reel - **Per reel**: 1 metered call + 1 direct CDN download - **Ends with**: a batch runner over a list of links ## what one call gives you The two requests do different jobs, and keeping them apart makes the rest obvious. The first turns a page URL into a media URL. The second is an ordinary download from Instagram's CDN — it never touches us and costs nothing. GET https://api.fastsaver.io/v1/fetch resolve step · 1.5 credits ``` curl --get "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://www.instagram.com/reel/Cx7YyZgIlPq/" \ -H "X-Api-Key: $FASTSAVER_KEY" ``` ``` { "ok": true, "id": "Cx7YyZgIlPq", "source": "instagram.com", "type": "video", "download_url": "https://scontent-fra5-1.cdninstagram.com/o1/v/...", "thumbnail_url": "https://scontent-fra5-1.cdninstagram.com/v/...", "width": 1080, "height": 1920, "duration": 27, "caption": "three minutes of sourdough, compressed." } ``` Note the --data-urlencode : reel links carry query strings like ?igsh= , and pasting one raw turns its parameters into yours. requests handles that when you pass params= . ## the version that works and nothing more Start with the smallest thing that puts an MP4 on disk. ``` import os import requests FETCH = "https://api.fastsaver.io/v1/fetch" KEY = os.environ["FASTSAVER_KEY"] LINK = "https://www.instagram.com/reel/Cx7YyZgIlPq/" meta = requests.get(FETCH, params={"url": LINK}, headers={"X-Api-Key": KEY}).json() open("reel.mp4", "wb").write(requests.get(meta["download_url"]).content) ``` Run it and you have the reel. Ship it and you have three problems. .content holds the whole file in memory. Neither request has a timeout, so a stalled socket hangs the process forever. And meta["download_url"] raises KeyError on failure, which is a strange way to learn a post was deleted. Keep the key in the environment from the first draft. ## stream the file to disk Replace the second request with a streamed write. stream=True defers the body, iter_content hands you fixed-size chunks, and the with block releases the connection even if the write throws. ``` from pathlib import Path def save(url: str, dest: Path) -> Path: """Stream a media URL to dest. Writes dest.part first, then renames.""" tmp = dest.with_suffix(dest.suffix + ".part") with requests.get(url, stream=True, timeout=(5, 30)) as r: r.raise_for_status() with tmp.open("wb") as f: for chunk in r.iter_content(chunk_size=65536): f.write(chunk) tmp.replace(dest) return dest ``` Two details earn their place. The timeout is a tuple — five seconds to connect, thirty between chunks; one number would cap the whole transfer and break on slow downloads. The .part rename means an interrupted run leaves an obviously incomplete file, not a truncated MP4 that plays for four seconds and stops. ## timeouts, retries and the ok flag Now the resolve step. A Session sets the key once and reuses the connection; urllib3 handles transport retries so you never write a sleep loop. ``` import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry FETCH = "https://api.fastsaver.io/v1/fetch" BALANCE = "https://api.fastsaver.io/v1/balance" session = requests.Session() session.headers["X-Api-Key"] = os.environ["FASTSAVER_KEY"] session.mount("https://", HTTPAdapter(max_retries=Retry( total=4, backoff_factor=1.5, # 0s, 1.5s, 3s, 6s status_forcelist=(429, 500, 502, 503, 504), allowed_methods=("GET",), respect_retry_after_header=True, ))) class ResolveError(RuntimeError): """The link cannot be resolved. Retrying will not change that.""" def resolve(link: str) -> dict: r = session.get(FETCH, params={"url": link}, timeout=(5, 60)) data = r.json() # read the body first — failures are JSON too if not data.get("ok"): raise ResolveError(data.get("detail", f"HTTP {r.status_code}")) return data ``` Note what is missing: there is no raise_for_status() . Read the body before you look at the status, because running out of credits answers a plain 400 — this API has no 402 — carrying detail: "Insufficient credits. Please top up to your account." . A raise_for_status() would flatten that into an opaque HTTPError and you would never see the reason. Treat ok as the success flag instead: a private profile or a deleted post is a real answer, so record it and move on. Automatic retries belong to 429 and 5xx, and the adapter above already owns those; the guides index has the whole error table. Before a long run, check what you have — /balance is free. ``` bal = session.get(BALANCE, timeout=10).json() print(bal["plan"], "-", bal["credits"], "credits,", bal["rpm_limit"], "req/min") # Pro - 98500 credits, 60 req/min -> about 65,000 reels at 1.5 each ``` ## why the CDN URL expires, and what that costs you download_url is signed by Instagram, not by us. The expiry sits in the query string, and once it passes the CDN returns 403 to everyone. That one fact decides the shape of your pipeline. - Resolve and download are one unit of work. Queue the URL for a worker that picks it up twenty minutes later and a share of those jobs are dead on arrival. - Store the id , never the URL. The shortcode makes a good key and filename; a cached download_url is a time bomb in your database. - Re-resolving costs 1.5 credits. That is the price of splitting the stages, and it beats a job that fails at 3 a.m. - Make 403 recoverable. The download stage should call resolve() again rather than failing an item for good. ## a batch runner over a list of links Put the pieces together: one link per line, skip reels already on disk without spending a credit, pace against your rate limit, log failures to a re-runnable file. ``` import re import time from pathlib import Path OUT = Path("reels") OUT.mkdir(exist_ok=True) SHORTCODE = re.compile(r"/(?:reel|reels|p|tv)/([A-Za-z0-9_-]+)") def shortcode(link: str) -> str: m = SHORTCODE.search(link) return m.group(1) if m else "" def run(list_file: str, gap: float = 1.2) -> None: links = [l.strip() for l in Path(list_file).read_text().splitlines() if l.strip()] failed = [] for i, link in enumerate(links, 1): code = shortcode(link) if code and (OUT / f"{code}.mp4").exists(): print(f"[{i}/{len(links)}] have {code}.mp4") continue try: meta = resolve(link) # 1.5 credits if meta["type"] != "video": raise ResolveError(f"type is {meta['type']}, not video") dest = save(meta["download_url"], OUT / f"{meta['id']}.mp4") print(f"[{i}/{len(links)}] {dest.name} {meta.get('duration')}s") except Exception as exc: print(f"[{i}/{len(links)}] FAIL {link}: {exc}") failed.append(f"{link}\t{exc}") if "Insufficient credits" in str(exc): failed.extend(links[i:]) # every remaining link fails the same way print("out of credits - stopping") break time.sleep(gap) if failed: Path("failed.txt").write_text("\n".join(failed)) run("links.txt") ``` gap is your rate budget and only covers the resolve call. Free's 10 a minute means gap=6 ; Pro's 60 brings it to 1; on Ultra and Mega the sleep stops mattering and your disk becomes the bottleneck — move to a thread pool and a token bucket. Skipping by shortcode before resolving is the difference between re-running a thousand-link list for free and paying 1,500 credits for files you already had. A /p/ link can resolve to an album, and an album has no top-level download_url at all — that is what the type guard is for, and the Instagram downloader API reference has that shape. Breaking out on the credits message matters too: once the balance is gone every remaining link burns a request for the same 400. ## instaloader, yt-dlp, and when not to pay instaloader is purpose-built for Instagram and needs no key for public posts. yt-dlp covers it alongside a thousand other sites. Both are free, and at a few downloads a day from a home connection either one beats an API call. They degrade in the same place. Instagram is aggressive toward unauthenticated traffic from data centre ranges, so the day your script moves to a VPS you collect 429s and login walls. The fixes are all work: a session cookie from a real account, kept warm, residential proxies, rotation, and an update whenever an extractor breaks on Instagram's schedule. A login also puts that account at risk. That maintenance is the whole product here. Both routes still see public content only. ## where an Instagram reel downloader breaks - Public content only. Private profiles fail by design, with a reason rather than a file. - Signed URLs expire — thumbnail_url too. Fetch the bytes in the same job and serve poster frames from your own storage. - Rate limits are per plan — 10 a minute on Free up to 900 on Mega. A 429 is pacing, not a bad link. - You get the file Instagram serves. Nothing is transcoded on the way through. - Instagram changes. Fixes land on our side and resolve() stays as written, but nobody in this business can promise "never breaks". - Copyright is yours. Downloading a reel is not a licence to republish it. Log the reason for everything you skip — a batch downloader without a failure log is one you cannot debug three weeks later. ## questions about downloading reels in Python **Q: How do I download an Instagram reel in Python without logging in?** A: Send the link to GET /fetch with your key in the X-Api-Key header, read download_url from the JSON, then requests.get that URL with stream=True and write the chunks to a file. No cookie, no headless browser. **Q: Why does the Instagram video URL stop working after a while?** A: It is a signed URL: Instagram's CDN puts an expiry in the query string and then answers 403 to everyone. Nothing on our side extends it. Download the bytes in the same function that resolved the link. **Q: Is instaloader or yt-dlp good enough instead of a paid API?** A: Often, yes — both are free and handle public reels from a home connection. They fall over once Instagram rate-limits your IP, which is normal for data centre traffic. After that you own a cookie jar, proxies and extractor fixes. **Q: How many reels can I download per minute?** A: Your plan's per-minute ceiling covers the resolve call only; the CDN download is not metered. Free allows 10 a minute, Pro 60, Ultra 250, Mega 900. GET /balance reports your rpm_limit and never spends credits. **Q: Can a Python script download reels from a private account?** A: No. Only publicly reachable content resolves, so a private profile comes back with ok: false and a reason instead of a file. ## Keep reading - Instagram downloader API — the full endpoint reference - TikTok without the watermark — sibling guide - All guides — shared setup and the error table - Live playground — paste a reel link, read the JSON ## Run the script against your own list The free tier is 1,000 credits — roughly 660 resolves — enough to take this batch runner all the way to production. Get a free API key Endpoint reference --- # YouTube to MP3 without yt-dlp on your own box URL: https://fastsaverapi.com/guides/youtube-to-mp3-api/ Summary: Tutorial for converting YouTube to MP3 over HTTP: POST /youtube/download with format "audio" (15 credits) returning a download_url on https://api.fastsaver.io/v1/tunnel to an mp3 or m4a file, a GET /youtube/info duration gate (2 credits) before spending, streaming Python and Node clients, the Telegram file_id path at /youtube/audio/tg-bot (7 credits on a cache hit, 15 on a miss), and a cost-and-maintenance comparison against self-hosted yt-dlp plus ffmpeg. Updated: 2026-08-10 YouTube to MP3 over HTTP is one POST request: /youtube/download with format: "audio" , and an MP3 you can fetch comes back in the response. This guide covers the duration check that keeps you from wasting credits, the Telegram file_id path and its two prices, working Python and Node, and a straight comparison against running yt-dlp and ffmpeg on hardware you pay for . - **Main call**: POST /youtube/download · format: "audio" - **Credits**: 15 · Telegram path 7 cached, 15 on a miss · 2 for a duration check - **You need**: an API key and an HTTP client — no ffmpeg - **Returns**: a download_url on https://api.fastsaver.io/v1/tunnel ## two ways out, and one of them is probably wrong for you Audio leaves this API through one of two endpoints. Choosing badly is the most expensive mistake on this page, so decide first and write code second. | Endpoint | Credits | You get | Pick it when | | --- | --- | --- | --- | | POST /youtube/downloadformat: "audio" | 15 | A URL to an audio file you fetch yourself. | Anything that is not a Telegram bot — web app, worker, CLI. | | POST /youtube/audio/tg-bot | 7 / 15 | A Telegram file_id. No bytes. | The destination is a Telegram chat, and nothing else. | Audio is not discounted against video: it bills the same 15 credits as a 720p download, because the number tracks producing a file rather than its size. The Telegram path has two prices, not one — 7 credits when we already hold that video_id , and the full 15 when we have to fetch the track first. It is still the right call for a Telegram chat even at 15, because the answer is a file_id rather than bytes you re-upload yourself, and the miss is what makes every later request for that track cost 7. ## gate on duration before you queue an extraction Anything that accepts a pasted link will eventually be handed a nine-hour rain-sound upload, and pulling the audio off that bills exactly what a three-minute single does. /youtube/info costs 2 credits, produces no file, and answers the one question a length ceiling needs. GET https://api.fastsaver.io/v1/youtube/info 2 credits · trimmed ``` curl "https://api.fastsaver.io/v1/youtube/info?url=https://youtu.be/8kZ3FvqM1nQ" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "video_id": "8kZ3FvqM1nQ", "title": "Night Bus - Full Mix", "author": "Blue Hour Radio", "duration": 512, "thumbnails": { "low": "https://i.ytimg.com/vi/8kZ3FvqM1nQ/mqdefault.jpg", "max": "https://i.ytimg.com/vi/8kZ3FvqM1nQ/maxresdefault.jpg" } } ``` duration is an integer count of seconds, so the gate is one comparison: 512 here, an eight-and-a-half minute mix, comfortably inside a fifteen-minute ceiling. title and author come along for free, which spares you a second lookup when you want to label the file or the chat message. The untrimmed response also enumerates every resolution with its file size. That side of the call is a video concern and lives on the YouTube downloader API reference — for audio there is nothing to choose between, so duration is the only field this guide reads. ## the extraction call Same endpoint as a video download; the only difference is the format value. POST https://api.fastsaver.io/v1/youtube/download 15 credits ``` curl -X POST "https://api.fastsaver.io/v1/youtube/download" \ -H "X-Api-Key: fs_sk_•••••••••••" \ -H "Content-Type: application/json" \ -d '{"url": "https://youtu.be/8kZ3FvqM1nQ", "format": "audio"}' ``` ``` { "ok": true, "video_id": "8kZ3FvqM1nQ", "duration": 512, "filename": "Night Bus - Full Mix (audio, youtube).mp3", "download_url": "https://api.fastsaver.io/v1/tunnel?id=UJLUoLGd1m2t1ScSTW1Lw...", "thumbnails": { "low": "https://i.ytimg.com/vi/8kZ3FvqM1nQ/mqdefault.jpg", "max": "https://i.ytimg.com/vi/8kZ3FvqM1nQ/maxresdefault.jpg" } } ``` One thing to be clear about before you build filenames or a player around this: the container follows the source, so you get mp3 or m4a depending on what YouTube served for that video. There is no parameter to force one, and no request that promises the other. - That filename is an illustration, not a contract — it is generated per job and its extension is whichever container came back. Read the field rather than stapling one on, and sanitise it before it reaches a filesystem: a video title is arbitrary text chosen by a stranger, slashes and all. - download_url points at https://api.fastsaver.io/v1/tunnel — the same API host you just called, on the /v1/tunnel path, not a separate download domain. The id is minted for this job and stops resolving about ten minutes later, so fetch it in the same worker run. - thumbnails is cover art for a player, with no second request. Check ok rather than the status code alone. A private, deleted or region-locked video returns a structured failure that retrying will not fix — and a failure is not free: every failure path bills a flat 0.1 credits, which is nothing once and real money in a retry loop. Running out of credits comes back as a plain 400 carrying a detail string — the shared error table lists the whole surface. ## python and node, end to end Python first: duration check, extraction, streamed write to disk. The tunnel URL does not need your API key, so fetch the file with a bare request and keep the header from travelling further than it must. ``` import os import requests BASE = "https://api.fastsaver.io/v1" api = requests.Session() api.headers["X-Api-Key"] = os.environ["FASTSAVER_KEY"] MAX_SECONDS = 15 * 60 def extract_audio(url: str, out_dir: str = ".") -> str: info = api.get(f"{BASE}/youtube/info", params={"url": url}, timeout=60).json() if not info.get("ok"): raise RuntimeError(info.get("detail", "info lookup failed")) if info["duration"] > MAX_SECONDS: raise ValueError(f"{info['title']} runs {info['duration']}s - skipped") job = api.post(f"{BASE}/youtube/download", json={"url": url, "format": "audio"}, timeout=600).json() if not job.get("ok"): raise RuntimeError(job.get("detail", "extraction failed")) path = os.path.join(out_dir, job["filename"]) with requests.get(job["download_url"], stream=True, timeout=600) as r: r.raise_for_status() with open(path, "wb") as fh: for chunk in r.iter_content(1 << 20): fh.write(chunk) return path print(extract_audio("https://youtu.be/8kZ3FvqM1nQ")) ``` Node, same shape, streaming through pipeline so a long track never sits in memory: ``` import { createWriteStream } from 'node:fs'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; const BASE = 'https://api.fastsaver.io/v1'; const auth = { 'X-Api-Key': process.env.FASTSAVER_KEY, 'Content-Type': 'application/json', }; const job = await fetch(BASE + '/youtube/download', { method: 'POST', headers: auth, body: JSON.stringify({ url: 'https://youtu.be/8kZ3FvqM1nQ', format: 'audio' }), }).then((r) => r.json()); if (!job.ok) throw new Error(job.detail ?? 'extraction failed'); const file = await fetch(job.download_url); if (!file.ok) throw new Error('tunnel returned ' + file.status); await pipeline(Readable.fromWeb(file.body), createWriteStream(job.filename)); console.log(job.filename, job.duration + 's'); ``` Those ten-minute timeouts are not padding. A worker has to pull the source and produce a file before it can answer, and an impatient client hangs up on jobs that were seconds from finishing. Give the POST minutes, and read the key from the environment rather than the file you are about to commit. ## when the answer is a file_id, not a file If the audio is going into a Telegram chat, the generic path is the long way round: 15 credits, bytes pulled down to your server, then the same bytes uploaded again while the user waits. POST /youtube/audio/tg-bot collapses all of it into one call that answers with a Telegram file_id — a reference to a copy already sitting on Telegram's own servers. Hand it to sendAudio and the bot replies as fast as Telegram can look up its own file. Budget it as two numbers. When we already hold that video_id , the call is 7 credits and returns almost at once. When we do not, we fetch the track first and the call bills the ordinary download rate of 15 — then stores it, so the next request for the same track is 7. The store is shared across callers, so a charting single is usually a hit before your bot ever asks for it, while a bot that lives on obscure uploads should plan its budget nearer 15 than 7 and treat the 7 as the reward for a catalogue that repeats. Two constraints come with the path: you address the track by video_id rather than by URL, and the reference is bound to the one bot username you sent with the request. The request and response shape, and why Telegram scopes references that way, are documented on the Telegram bot media API page. The Telegram music bot guide wires the same call into a search-and-send bot with an aiogram 3 handler. ## should you just run yt-dlp yourself? Sometimes, yes. For twenty albums on your laptop, install yt-dlp, point it at ffmpeg and keep every flag: exact bitrate, chapter splitting, embedded thumbnails. A local binary beats an HTTP call for a one-off, and this page will not pretend otherwise. The trade changes when extraction is a feature other people rely on. The software is free; everything attached to running it in production is not. | What you own | yt-dlp + ffmpeg, self-hosted | This API | | --- | --- | --- | | Cost | Nothing to licence. | 15 credits a call. | | Control | Total — every codec, container and flag. | One format value. | | Blocking | Yours. Datacenter ranges get challenged, so residential proxies become a standing bill. | Ours. | | Upstream changes | Yours to track and redeploy; a pinned version rots quietly. | Ours, behind an unchanged endpoint. | | Compute and egress | Transcoding is CPU-bound, and every byte enters and leaves your host. | One fetch from the tunnel URL. | Put a number on it. The 100,000 credits on the $9 Pro plan are roughly 6,600 extractions a month — about one small VPS plus a modest proxy subscription, before you count the afternoon you lose the week something upstream changes. Below a few hundred files a month the hosted call wins on total cost; far above it, self-hosting wins on unit cost and you get the flags back. Both answers are defensible. Guessing is not. ## where YouTube audio extraction gets awkward - A login is a wall. Members-only uploads, purchases, private links and age-gated videos have no audio to give you; the call comes back as a structured failure. - The transfer link is disposable. It is a https://api.fastsaver.io/v1/tunnel id minted for one job and good for roughly ten minutes, so write the bytes to your own storage while the worker is still awake, and key your cache by video_id rather than by URL. - Nothing is normalised. Loudness, bitrate and tags arrive however the source was encoded. If your product promises consistent audio, ffmpeg is still in your stack — it just no longer has to fetch anything. - A DJ set is a longer job than a single. No duration ceiling is documented, but throughput falls with runtime, so size your queue and your client timeout for the worst link a user will paste. - Your plan sets the pace. A free key is allowed 10 calls a minute and a Mega key 900, with Pro and Ultra in between. Seeing a 429 means the queue is running ahead of the plan, not that the link is bad. - The upstream player is a moving target. When it shifts, the repair happens on our side of the endpoint and your code never learns about it — that maintenance is most of what the credits buy. Cache by video_id . One track requested by fifty users is fifty 15-credit charges unless you keep the first result — the easiest way to halve a music bot's bill. The Telegram path is the one exception, because the cache there is ours, which is exactly why a repeat costs 7 instead of 15. And the unglamorous part: a key buys extraction, not rights. Whether you may store, replay or monetise a particular recording is a question for whoever owns it, and the answer stays between you and them. ## questions about YouTube audio extraction **Q: How do I convert a YouTube video to MP3 with an API?** A: One POST to /youtube/download with a body of {"url": "…", "format": "audio"} and your X-Api-Key header. The response carries a download_url ; fetch it and you have the file. No job id to poll, no callback to register — the call returns when the audio is ready. **Q: Does the endpoint return an actual .mp3 file?** A: Usually, but the container tracks the source rather than the request: mp3 for most videos, m4a for some. Read the filename field instead of appending an extension yourself. A guaranteed container, a fixed bitrate or written ID3 tags is a mastering step you still own — this endpoint solves fetching, not encoding. **Q: What does one audio extraction cost?** A: 15 credits — the same as a 720p video, because the price tracks the work of resolving and producing a file rather than how many bytes you end up with. The optional /youtube/info lookup adds 2. The free tier's 1,000 credits cover about 66 extractions; Pro's 100,000 cover roughly 6,600. **Q: Should I just run yt-dlp and ffmpeg myself?** A: For a handful of files on your own machine, yes — install yt-dlp and keep every flag. The calculation changes when extraction is a feature other people depend on, because you then own bot detection, proxy bills, player-change fallout, transcoding CPU and egress. The API rents that maintenance instead of staffing it. **Q: Why does /youtube/audio/tg-bot take a video_id and a bot username instead of a URL?** A: Because it does not hand you bytes. It answers with a Telegram file_id , and Telegram binds such a reference to a single bot — hence bot_username . The video_id doubles as the cache key, and that cache is shared across every caller, so an already-stored track comes back without anything being downloaded: 7 credits. A track nobody has asked for yet gets fetched on the spot and bills the ordinary download price, 15. The Telegram bot media API page documents the call. ## Keep reading - YouTube downloader API — all four endpoints - Build a Telegram music bot — search then send - Telegram bot media API — how file_id delivery works - Live playground — run the audio call in a browser ## Extract one track before you write the worker Pick /youtube/download in the playground, set format to audio and read the JSON. Your 1,000 free credits are 66 tracks. Open the playground Get a free API key --- # getting a TikTok video without the watermark, and why editing it out fails URL: https://fastsaverapi.com/guides/tiktok-no-watermark-api/ Summary: Narrative guide to watermark-free TikTok downloads: where the burned-in watermark comes from, why cropping, inpainting, re-encoding, mirror sites and unofficial app endpoints degrade quality or break, and how GET /fetch at 1.5 credits returns the clean render, with a Node example that branches on type (albums carry an items array) and an async Python batch runner that costs a run correctly — successes at 1.5 credits, failed resolves at a flat 0.1. Updated: 2026-08-10 Watermark pixels are burned into the frames of the file TikTok hands you, so a TikTok video with no watermark is not something you edit into existence — it is a different render that already exists. This guide covers where the watermark comes from, what cropping, blurring and re-encoding each cost you, and why resolving the link with one API call is a different problem entirely. - **Endpoint**: GET /fetch - **Cost**: 1.5 credits per link - **Output**: the clean render — not a crop, not a re-encode - **You need**: an API key and any HTTP client ## where the TikTok watermark actually comes from An upload does not stay the file you sent. TikTok transcodes it into playback renditions and, along the way, produces a watermarked copy: the animated watermark plus the creator's handle, composited into the frames. That copy is what the app's save button hands you. It is deliberate — the watermark is how a clip carries its source into Reels, Shorts and every repost account in between. And it moves. The watermark drifts on a slow loop, so its position depends on how long the clip runs, and a short video may only ever show it in one spot. That is why a fix tuned on one clip fails on the next, and why "just crop the bottom right" is advice from someone who tested exactly one video. ## the five workarounds, and what each one costs Everything that starts from the stamped file is damage control. Each route below is a real thing people ship; none of them gets the original pixels back, because the original pixels were never in the file they are editing. The honest accounting: ### 1. crop it out Take a 1080×1920 clip. The watermark plus padding occupies a band around 150 pixels tall, and because it travels you must cut that band everywhere it appears, not just in your test frame. Top and bottom gone, you are at 1080×1620 — a sixth of the picture, and no longer 9:16, so a vertical player letterboxes it or crops the sides to fit. That is two re-framings of someone else's shot to hide a logo, and a caption burned near the edge goes with it. Scaling the crop back up to 1920 does not rescue the framing either: you are interpolating 1620 rows into 1920, which is a blur applied to the whole frame to hide a logo in one corner. ### 2. blur or inpaint the region Better on paper, worse in a feed. Inpainting is per-frame work, so cost scales with duration rather than with file size, and a moving target has to be tracked before it can be painted over. What you get is a rectangle of invented pixels that drifts around the frame — motion draws the eye, so viewers notice the patch faster than they noticed the logo. On a static background it can pass; over hair, water, crowds or a moving camera it smears, and the smear is what your thumbnail catches. ### 3. re-encode and hope What TikTok serves is already a lossy encode of a file the phone encoded lossily; yours makes a third generation. At the same bitrate you lose detail the previous pass kept; at double, you faithfully preserve the artefacts and double your storage. Banding shows first on gradients, skin and night footage. And the point worth saying plainly: re-encoding does not touch the watermark at all. It is a cost with no corresponding benefit — people reach for it because it is the step that follows a crop or a blur, not because it removes anything. ### 4. paste it into a mirror site Fine for one video on a phone. As a dependency: no contract, no status page, an ad interstitial, a shared queue, and sometimes a transcode to fixed quality on the way out — point 3 with extra steps, run by someone whose incentive is bandwidth, not fidelity. You also cannot tell from the output whether you were handed the clean render or a cropped one, which is a bad property for something in a pipeline. ### 5. call the app's private endpoints yourself The one route that can reach the unstamped file, and the one that breaks worst. It works until the signing scheme changes, and the failure mode is nasty: instead of an error you often get the stamped file back, so the pipeline quietly ships watermarked video until a human notices in a published post. Add device fingerprints, region rules and a maintenance burden that lands on whoever is on call. | Route | What it actually does | What it costs you | | --- | --- | --- | | Crop | Cuts every band the watermark travels through | A sixth of the frame and the 9:16 aspect ratio | | Blur / inpaint | Paints invented pixels over a moving target | Per-frame compute, plus an artefact viewers spot first | | Re-encode | Adds a third lossy generation | Detail or storage — and the watermark survives it | | Mirror site | Hands the problem to someone else's server | No contract, no status page, often a fixed-quality transcode | | Private endpoints | Impersonates the app to reach the clean render | Breaks on every signing change, and fails back to the stamped copy silently | ## the file you actually want already exists Reframe it. You are not erasing something from an image; you are addressing a different object. The unstamped rendition is on TikTok's infrastructure — it has to be, since the stamp goes on a copy — and the job is finding which URL points at it. That is a resolution problem, and resolution is the part that rots: link formats change, hosts rotate, signatures grow parameters. Behind /fetch that upkeep is ours; your side stays a URL and a header. ## one request, one clean file Send the link as a query parameter and read download_url out of the JSON. No flag, no mode, no post-processing — clean is the only thing this returns. GET https://api.fastsaver.io/v1/fetch 1.5 credits ``` curl -G "https://api.fastsaver.io/v1/fetch" \ --data-urlencode "url=https://www.tiktok.com/@studio/video/7419028374651029384" \ -H "X-Api-Key: fs_sk_•••••••••••" ``` ``` { "ok": true, "id": "7419028374651029384", "source": "tiktok.com", "type": "video", "download_url": "https://v16-webapp-prime.tiktok.com/video/tos/...", "thumbnail_url": "https://p16-sign.tiktokcdn-us.com/tos-...", "width": 1080, "height": 1920, "duration": 19, "caption": "sunrise, second attempt" } ``` Check width and height against what the app plays before trusting any downloader, this one included: matching dimensions prove nothing was cropped or scaled. In Node it is a function and a write — resolve, then pull the bytes in the same run, because the CDN link is signed and short-lived. ``` import { writeFile } from 'node:fs/promises'; const KEY = process.env.FASTSAVER_KEY; async function resolve(link) { const u = new URL('https://api.fastsaver.io/v1/fetch'); u.searchParams.set('url', link); const res = await fetch(u, { headers: { 'X-Api-Key': KEY } }); const data = await res.json(); if (!data.ok) throw new Error(data.detail ?? 'could not resolve ' + link); return data; } const post = await resolve('https://vm.tiktok.com/ZMAvfLFYc/'); // A slideshow carries no top-level download_url — its slides are in post.items. if (post.type === 'album') throw new Error('photo post — save each entry in post.items'); const ext = post.type === 'video' ? '.mp4' : '.jpg'; const bytes = await fetch(post.download_url).then((r) => r.arrayBuffer()); await writeFile(post.id + ext, Buffer.from(bytes)); ``` The type check matters, and not only as a formality: a slideshow answers album and that payload has no top-level download_url at all — the slides sit in an items array — while a single-image post answers image and behaves exactly like the video branch. The endpoint reference documents both shapes field by field. ## doing it for a list of links At volume the constraint is your plan's requests per minute, not the platform. Cap concurrency yourself, back off on a 429, and keep failures as data so one dead link does not take the run down. ``` import asyncio, httpx API = "https://api.fastsaver.io/v1/fetch" HEAD = {"X-Api-Key": "fs_sk_•••••••••••"} GATE = asyncio.Semaphore(8) # keep this under your plan's rpm async def resolve(client, link): for attempt in range(3): async with GATE: r = await client.get(API, params={"url": link}, headers=HEAD, timeout=60) if r.status_code == 429: # rate limited, not rejected await asyncio.sleep(2 ** attempt) continue data = r.json() if data.get("ok"): return data detail = data.get("detail", "") if "Insufficient credits" in detail: # a plain 400, not a 402 — stop the run raise SystemExit(detail) return {"ok": False, "link": link, "detail": detail} return {"ok": False, "link": link, "detail": "rate limited"} async def main(links): async with httpx.AsyncClient() as client: return await asyncio.gather(*(resolve(client, l) for l in links)) links = open("links.txt").read().split() results = asyncio.run(main(links)) good = [r for r in results if r["ok"]] bad = len(results) - len(good) print(len(good), "resolved,", bad, "failed") # a resolve bills 1.5 credits; a failure bills a flat 0.1, not nothing print(round(len(good) * 1.5 + bad * 0.1, 1), "credits spent") ``` That last line is deliberately not len(results) * 1.5 . A failed resolve is cheap but not free: a private post, a deleted video or a malformed URL charges a flat 0.1 credits instead of the 1.5 a success costs. Negligible on one bad link; a real line item if you replay a list of a few thousand dead ones every night. Running out of credits is the one failure worth aborting on, and it does not arrive as a 402 — there is no 402 here. You get a 400 whose detail reads Insufficient credits. Please top up to your account. , so match the message, not the status. The guides index lists the whole error surface once. Resolving is cheap and fast; downloading is neither. Keep them separate so a slow transfer never holds a slot in the resolve pool — and start transfers promptly, because a signed URL from an hour ago is probably dead. ## what a clean TikTok render still does not solve - Public posts only. Private accounts, friends-only posts and anything behind a login are out of reach by design. - Deleted stays deleted. A removed or moderated post fails permanently — do not queue a retry. - The URL expires before the file does. Keep the bytes if you need them later, or keep the post ID and re-resolve. - Rate limits are per plan. 10 requests a minute on Free, up to 900 on Mega. A 429 is back-pressure: slow the worker, do not rotate keys. - TikTok will change something. That repair is ours, but a bad hour is possible; incident notes go to the Telegram channel. - Attribution is now your problem. A clean file transfers no right to use it. Credit the creator, get permission, or leave it alone. ## questions people actually search for **Q: Can I remove the watermark from a TikTok file I already downloaded?** A: Not cleanly. The watermark is in the pixels, so every fix is an edit: cropping changes the framing, painting over it leaves a smear, re-encoding spends a generation of quality. The undamaged frames only exist on TikTok's side — resolve the link again instead. **Q: Is the file the API returns re-encoded or resized?** A: Neither. Nothing is transcoded in the middle — you get a URL to a stored file, and the width and height fields report its dimensions before you download a byte. If they match what the app plays, no scaling happened. **Q: Why do free TikTok watermark remover sites keep breaking?** A: Most wrap a single unofficial request path. When TikTok changes it — every few months — the site errors out, or silently falls back to the stamped copy. Several also re-encode to a fixed quality to cut their bandwidth bill. **Q: How many TikTok links can I process per minute?** A: Your plan's rate limit decides, not the platform: 10 requests a minute on Free, up to 900 on Mega. Each link costs 1.5 credits, and GET /balance is free, so a worker can poll it to see what is left. **Q: Does downloading without the watermark make the video mine to repost?** A: No. The watermark is attribution, not a licence, and removing it changes nothing about who owns the clip. Reposting still needs the creator's permission, and TikTok's terms still apply to you. ## Keep reading - TikTok downloader API — the endpoint reference - Instagram reels in Python — same pattern, other platform - All guides — shared setup and the error table - Live playground — paste a link, read the JSON ## Resolve one link and compare it yourself Run a TikTok URL through the playground, download both copies, and look at the frames side by side. Open the playground Get a free API key ---