Short answer: Node.js 18 and newer include
fetch. Submit/api/music/createonce, persist the returnedsong_id, query/api/music/queryevery 3-5 seconds, and download the file after the status reachescompletedorplayable.
Minimal working example
import fs from "node:fs/promises";
const BASE = "https://www.suno-api.io";
const KEY = process.env.SUNO_API_KEY;
async function postJson(path, body, retries = 2) {
const response = await fetch(BASE + path, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(120_000),
});
if (response.status === 429 && retries > 0) {
const wait = Number(response.headers.get("retry-after") || 15);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
return postJson(path, body, retries - 1);
}
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return response.json();
}
const submitted = await postJson("/api/music/create", {
description: "bright city pop with a female vocal",
model: "suno-v6",
});
const clips = submitted?.data?.clips ?? [];
const songId = clips[0]?.song_id;
if (!songId) throw new Error("No song_id returned");
const deadline = Date.now() + 8 * 60 * 1000;
while (Date.now() < deadline) {
const result = await postJson("/api/music/query", { song_id: songId });
const status = String(result?.data?.status || "").toLowerCase();
if (["completed", "complete", "playable"].includes(status)) break;
if (["failed", "error"].includes(status)) throw new Error(JSON.stringify(result));
await new Promise((resolve) => setTimeout(resolve, 4000));
}
const media = await postJson("/api/music/download-url", {
song_id: songId,
kind: "song",
});
const url = media?.data?.url ?? media?.data?.audio_url;
if (!url) throw new Error("No media URL after completion");
const audio = await fetch(url, { signal: AbortSignal.timeout(180_000) });
if (!audio.ok) throw new Error(`Download failed: ${audio.status}`);
await fs.writeFile("output.mp3", Buffer.from(await audio.arrayBuffer()));
console.log({ songId, file: "output.mp3" });Production rules
- Persist the song ID and client request ID before polling.
- Keep create and query in separate functions.
- Limit polling concurrency across a batch.
- Back off on 429 instead of starting another generation.
- Copy finished audio to durable storage when the business requires persistent delivery.
Why two tracks may finish at different times
A normal generation returns two clips. Treat each song_id as an independent task. Do not assume that one completed clip means the other has finished.
FAQ
Does Node need an extra HTTP package?
No on Node 18+. Older runtimes need a fetch implementation such as undici or an upgrade.
How do I avoid duplicate charges?
Call the create endpoint once. A network timeout does not automatically prove that the task was not created, so reconcile with the request ID before attempting another generation.
What should happen after a 429?
Pause that request, honor Retry-After, and reduce the number of concurrent pollers.
Related: Python example, polling and timeout guide, query API, and media download API.
¥0.6 per generation, 2 tracks, unlimited downloads
Runs on the Suno V6 engine. Download MP3 or WAV as often as you like. Signing up needs an email address, not a phone number.


