saveapi.org

Download social media videos in Node.js

The whole client is a fetch call. What takes longer is everything around it: turning the resolved URL into a download your own user can click, keeping a slow source from hanging your request handler, and knowing which failures are permanent. All of it is below.

Updated

The first call

Node has had fetch built in since 18, so there is no client library to install. AbortSignal.timeout is the part worth copying — without it a slow source keeps a handler alive until the platform kills it.

JavaScript
const API = "https://api.saveapi.org/v1/download";

async function resolve(link) {
  const res = await fetch(API + "?url=" + encodeURIComponent(link), {
    headers: { Authorization: "Bearer sk_live_xxxx" },
    signal: AbortSignal.timeout(30_000),
  });
  return res.json();
}

Read the response

medias[] holds every file in the post in the order it appears. A TikTok slideshow gives you image entries and a separate audio entry for the background track, so find() by type rather than reaching for medias[0] and hoping.

JavaScript
const data = await resolve("https://www.tiktok.com/@user/video/123");

if (!data.success) throw new Error(data.error.code);

const video = data.medias.find((m) => m.type === "video");
const images = data.medias.filter((m) => m.type === "image");
const audio = data.medias.find((m) => m.type === "audio");

Stream the file to your user

If your users click a download button, do not buffer the file and do not redirect them to the source CDN — the link is signed and expires. Pipe the upstream body straight into your response with a Content-Disposition header and the browser saves it under a name you chose.

JavaScript
import { Readable } from "node:stream";

app.get("/save", async (req, res) => {
  const data = await resolve(req.query.url);
  if (!data.success) return res.status(400).json(data.error);

  const upstream = await fetch(data.medias[0].url);
  res.setHeader("Content-Type", "video/mp4");
  res.setHeader("Content-Disposition", 'attachment; filename="video.mp4"');
  Readable.fromWeb(upstream.body).pipe(res);
});

Retry only what a retry can fix

Timeouts and upstream errors deserve a second attempt. Private, missing and unsupported are final answers — retrying them wastes wall-clock and, where the error is billed, a credit. Keep the retryable codes in a Set so the rule lives in one place.

JavaScript
const RETRY = new Set(["UPSTREAM_TIMEOUT", "UPSTREAM_ERROR"]);

async function withRetry(link) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const data = await resolve(link);
    if (data.success) return data;
    if (!RETRY.has(data.error.code)) throw new Error(data.error.message);
  }
  throw new Error("source unavailable");
}

Read the headers you already have

Every response carries the per-minute limit, what is left of it and the credits remaining. That is enough to back off before you are throttled and to alarm on a pack running out, with no extra request.

JavaScript
const res = await fetch(API + "?url=" + encodeURIComponent(link), { headers });

const perMinute = Number(res.headers.get("X-RateLimit-Limit"));
const left = Number(res.headers.get("X-RateLimit-Remaining"));
const credits = Number(res.headers.get("X-Credits-Remaining"));

Check a link before you spend a credit

When users paste arbitrary text, /v1/detect tells you which platform a URL belongs to without touching your credits. It counts against the rate limit, so it is a validation step rather than a free probe loop.

JavaScript
const check = await fetch(
  "https://api.saveapi.org/v1/detect?url=" + encodeURIComponent(userInput),
  { headers },
);
const { platform } = await check.json();   // kredit sarflanmaydi

Questions before you build

Can I call this from the browser?
You can, but do not: the key would be in your bundle for anyone to read. Put the call in a route on your server, which is also where the retry logic and rate-limit handling belong.
Is there a TypeScript type for the response?
The shape is stable and small enough to declare yourself — success, platform, type, medias[], optional formats[], meta and credits. Declaring it locally also means a new optional field cannot break your build.
Why stream instead of redirecting to the media URL?
The CDN link is signed and short-lived, and a redirect exposes it. Streaming through your own route keeps the filename, the Content-Type and the link lifetime under your control.

Platform references used here

Other guides

Run this guide with your own key

Key in under a minute. 1,000 free credits, no card, no expiry.

Get a key and try itFull API reference