StemSwell REST API v1
The public REST API exposes StemSwell assets, the complete deployed tool catalog, jobs, downloads, and account credit state. It uses the same job cores as the web app, so plan limits, credit reservation/settlement, free-tier daily throttling, overage quotes, and restricted-tool gating are identical.
Base URL and authentication
Production:
https://us-central1-waveforge-audio.cloudfunctions.net/api/v1
Firebase emulator:
http://127.0.0.1:5001/demo-waveforge/us-central1/api/v1
Create a key on the StemSwell Account page. The secret is shown once. Send it as
a bearer token on every route except the public GET /v1/tools catalog:
export WF_API_URL='https://us-central1-waveforge-audio.cloudfunctions.net/api/v1'
export WF_API_KEY='wf_replace_with_your_key'
curl -sS "$WF_API_URL/account" \
-H "Authorization: Bearer $WF_API_KEY"
API keys replace Firebase App Check for this surface. Do not put a key in browser code, a URL, or source control.
Errors
Every error is JSON:
{
"error": {
"code": "overage-approval-required",
"message": "This job costs 2 credits at 2× the 6-minute baseline. Set approveOverage:true to continue.",
"details": {
"code": "overage-approval-required",
"quote": { "credits": 2, "lengthFactor": 2 }
}
}
}
Normal HTTP statuses apply: 400 invalid input, 401 missing/unknown/revoked
key, 403 ownership or download-allowlist failure, 404 missing resource/tool,
402 insufficient credits or unapproved length overage, 422 an invalid full
editor-session document, 429 concurrency, download, or daily-throttle limit,
and 5xx transient server failures.
Routes
| Method | Route | Description |
|---|---|---|
GET | /v1/tools | Deployed catalog. Anonymous callers see public tools; an owner/superuser key also sees restricted tools. |
GET | /v1/account | Plan, role, and available/reserved credits. |
POST | /v1/assets | Mint an audio upload: {filename, contentType, sizeBytes?}. |
POST | /v1/assets/{assetId}/finalize | Verify and probe an uploaded asset, then add it to the library. |
GET | /v1/assets | List up to 100 library assets, newest first. |
DELETE | /v1/assets/{assetId} | Delete a settled asset and its storage objects. |
POST | /v1/jobs | Reserve credits and immediately start a job using existing assets/results. |
GET | /v1/jobs | List jobs newest first; accepts limit (1–100) and opaque cursor. |
GET | /v1/jobs/{jobId} | Read status, error, outputs, charge, and metrics. |
GET | /v1/jobs/{jobId}/download?path=… | Mint a short-lived URL for an allowlisted artifact. |
POST | /v1/editor/sessions | Create a session with {name, tracks?: [{assetId}]}. |
GET | /v1/editor/sessions | List the key owner's sessions. |
GET | /v1/editor/sessions/{sessionId} | Load the complete persisted editor document. |
PUT | /v1/editor/sessions/{sessionId} | Replace the editable document fields using the web editor's schema. |
DELETE | /v1/editor/sessions/{sessionId} | Delete an owned session (audio assets remain in the library). |
POST | /v1/editor/sessions/{sessionId}/apply-tool | Start a billed tool job from a track and fold settled audio outputs back into the session. |
POST | /v1/editor/sessions/{sessionId}/export | Mint a WAV render upload for {format:"wav"}. |
POST | /v1/editor/sessions/{sessionId}/export/{jobId}/finalize | Verify the uploaded render and settle its pollable library job. |
POST /v1/jobs accepts:
{
"tool": "trim",
"input": { "assetId": "opaque-asset-id" },
"params": { "start_sec": 10, "end_sec": 30 },
"approveOverage": false
}
Use inputs for a two-input tool:
{
"tool": "reference-master",
"inputs": [
{ "assetId": "source-asset-id" },
{ "assetId": "reference-asset-id" }
],
"params": {}
}
Each input is exactly one of assetId or an owned prior result gsPath. REST
jobs do not accept inline files: upload assets first. Text tools accept
textInput, which is folded into their canonical text or prompt parameter.
For audio longer than the six-minute baseline, the first request returns 402
with the exact quote; repeat it with "approveOverage": true.
The tools response includes each tool's friendly title, category, baseline
credits, input count, GPU class, and shallow parameter summaries
(name/type/required/default, with enum values where applicable).
Upload size and transport
The old “about 32 MB” limit was not an upload limit. Cloud Run functions v2 do have a 32 MB uncompressed HTTP request limit, but StemSwell sends only small JSON metadata through its callable and REST functions. Audio bytes travel directly from the client to Cloud Storage, so the function request limit does not apply.
The effective StemSwell limits are the per-plan gates: 50 MiB on Free, 200 MiB on
Plus, and 1 GiB on Pro. The server verifies the final object size from Storage
metadata; sizeBytes is only an early, friendly check. Cloud Storage itself
allows objects up to 5 TiB regardless of whether they arrive in one request or
through a resumable session. See the official
Cloud Run functions quotas and
Cloud Storage object limits.
POST /v1/assets always returns the existing uploadUrl. When sizeBytes is
greater than 32 MiB in production, it also returns resumableUrl; otherwise that
field is null. This is additive, so existing clients and small files keep the
single signed PUT flow unchanged:
{
"assetId": "opaque-asset-id",
"uploadUrl": "https://storage.googleapis.com/…signed PUT…",
"resumableUrl": "https://storage.googleapis.com/…signed POST…",
"gsPath": "gs://waveforge-audio.firebasestorage.app/…"
}
The resumable protocol is:
- Send a
POSTtoresumableUrlwith the exact objectContent-Typeandx-goog-resumable: start. Save the session URI from theLocationresponse header. The signed URL authenticates only this initiation request. - Send 8 MiB chunks to the session URI with
PUTandContent-Range: bytes START-END/TOTAL. GCS returns308 Resume Incompleteuntil the final chunk, then200or201. - After a timeout or interrupted request, send an empty
PUTwithContent-Range: bytes */TOTAL. A308response reports persisted bytes inRange: bytes=0-LAST; continue atLAST + 1. A200/201means the prior request completed. - Finalize the StemSwell asset exactly as before. The session URI is a bearer credential and remains active for up to one week, so do not log or share it.
Eight MiB is a multiple of GCS's required 256 KiB chunk quantum. Browser clients
also need bucket CORS to expose Location and Range; the repository's
infra/gcs-cors.json includes both. The Storage emulator does not implement or
validate this protocol. Use it only for the legacy media POST path. See Google's
resumable upload protocol
for the underlying wire contract.
Maintainers can reproduce the no-deploy real-GCS check with
scripts/gcs-resumable-smoke.mjs after building @waveforge/shared and
@waveforge/functions. It drives the local /v1/assets adapter, deliberately
tears down a chunk request, probes/resumes a 104 MiB object, verifies the stored
size, and deletes the smoke object. The script requires runtime service-account
credentials through GOOGLE_APPLICATION_CREDENTIALS plus STORAGE_BUCKET.
Node 20 resumable asset example
This example streams a file in bounded 8 MiB buffers and probes the session before continuing whenever a chunk request throws. The same finalize call used by single-PUT clients completes the asset:
import { open, stat } from 'node:fs/promises';
const sourcePath = process.argv[2];
if (!sourcePath) throw new Error('usage: node upload.mjs ./source.wav');
const { size } = await stat(sourcePath);
const contentType = 'audio/wav';
const assetResponse = await fetch(`${process.env.WF_API_URL}/assets`, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.WF_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
filename: 'source.wav',
contentType,
sizeBytes: size,
}),
});
if (!assetResponse.ok) throw new Error(await assetResponse.text());
const asset = await assetResponse.json();
if (!asset.resumableUrl) throw new Error('File did not qualify for resumable upload.');
const initiation = await fetch(asset.resumableUrl, {
method: 'POST',
headers: {
'content-type': contentType,
'x-goog-resumable': 'start',
},
});
if (!initiation.ok) throw new Error(`initiation failed: ${initiation.status}`);
const sessionUrl = initiation.headers.get('location');
if (!sessionUrl) throw new Error('initiation response did not include Location');
const CHUNK_BYTES = 8 * 1024 * 1024;
const storedOffset = (response) => {
const match = /^bytes=0-(\d+)$/.exec(response.headers.get('range') ?? '');
return match ? Number(match[1]) + 1 : 0;
};
const probe = async () => {
const response = await fetch(sessionUrl, {
method: 'PUT',
headers: {
'content-length': '0',
'content-range': `bytes */${size}`,
},
});
if (response.status === 200 || response.status === 201) return size;
if (response.status !== 308) throw new Error(`probe failed: ${response.status}`);
return storedOffset(response);
};
const file = await open(sourcePath, 'r');
let offset = 0;
try {
while (offset < size) {
const length = Math.min(CHUNK_BYTES, size - offset);
const chunk = Buffer.allocUnsafe(length);
await file.read(chunk, 0, length, offset);
let response;
try {
response = await fetch(sessionUrl, {
method: 'PUT',
headers: {
'content-length': String(length),
'content-range': `bytes ${offset}-${offset + length - 1}/${size}`,
},
body: chunk,
});
} catch {
offset = await probe();
continue;
}
if (response.status === 200 || response.status === 201) {
offset = size;
} else if (response.status === 308) {
offset = storedOffset(response);
} else if ([400, 408, 429, 500, 502, 503, 504].includes(response.status)) {
offset = await probe();
} else {
throw new Error(`chunk failed: ${response.status}`);
}
}
} finally {
await file.close();
}
const finalized = await fetch(
`${process.env.WF_API_URL}/assets/${encodeURIComponent(asset.assetId)}/finalize`,
{ method: 'POST', headers: { authorization: `Bearer ${process.env.WF_API_KEY}` } },
);
if (!finalized.ok) throw new Error(await finalized.text());
console.log(await finalized.json());
Walkthrough 1: upload → trim → poll → download
This example requires jq. Production v4 signed upload URLs are PUT-only.
SOURCE_BYTES=$(wc -c < ./source.wav)
ASSET=$(
curl -sS -X POST "$WF_API_URL/assets" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d "{\"filename\":\"source.wav\",\"contentType\":\"audio/wav\",\"sizeBytes\":$SOURCE_BYTES}"
)
ASSET_ID=$(printf '%s' "$ASSET" | jq -r .assetId)
UPLOAD_URL=$(printf '%s' "$ASSET" | jq -r .uploadUrl)
curl -sS -X PUT "$UPLOAD_URL" \
-H 'Content-Type: audio/wav' \
--upload-file ./source.wav
curl -sS -X POST "$WF_API_URL/assets/$ASSET_ID/finalize" \
-H "Authorization: Bearer $WF_API_KEY" | jq
JOB=$(
curl -sS -X POST "$WF_API_URL/jobs" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg asset "$ASSET_ID" '{
tool:"trim",
input:{assetId:$asset},
params:{start_sec:10,end_sec:30,output_format:"flac"}
}')"
)
JOB_ID=$(printf '%s' "$JOB" | jq -r .jobId)
while :; do
STATUS=$(
curl -sS "$WF_API_URL/jobs/$JOB_ID" \
-H "Authorization: Bearer $WF_API_KEY"
)
STATE=$(printf '%s' "$STATUS" | jq -r .status)
printf 'status=%s\n' "$STATE"
[ "$STATE" = done ] && break
[ "$STATE" = failed ] && { printf '%s\n' "$STATUS" | jq; exit 1; }
sleep 2
done
OUTPUT_PATH=$(printf '%s' "$STATUS" | jq -r '.outputs[0].path')
DOWNLOAD=$(
curl -sS -G "$WF_API_URL/jobs/$JOB_ID/download" \
-H "Authorization: Bearer $WF_API_KEY" \
--data-urlencode "path=$OUTPUT_PATH"
)
curl -L "$(printf '%s' "$DOWNLOAD" | jq -r .url)" -o trimmed.flac
The Storage emulator uses its JSON media endpoint rather than a real v4 signed URL. For the same walkthrough locally, replace the upload command with:
curl -sS -X POST "$UPLOAD_URL" \
-H 'Content-Type: audio/wav' \
--data-binary @./source.wav
That emulator POST does not validate production PUT semantics or browser CORS.
With the emulator suite seeded and python -m waveforge.mock_server running,
node scripts/api-e2e.mjs automates this complete key-to-download loop.
Walkthrough 2: remove vocals
Reuse an uploaded ASSET_ID and run karaoke:
curl -sS -X POST "$WF_API_URL/jobs" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg asset "$ASSET_ID" '{
tool:"karaoke",
input:{assetId:$asset},
params:{}
}')" | jq
Poll the returned job as above. The completed outputs contain labeled
instrumental and vocals artifacts.
Walkthrough 3: add EQ to a previous result
Take an audio output path from any completed job and chain it directly:
PREVIOUS_PATH=$(
curl -sS "$WF_API_URL/jobs/$JOB_ID" \
-H "Authorization: Bearer $WF_API_KEY" |
jq -r '.outputs[0].path'
)
curl -sS -X POST "$WF_API_URL/jobs" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg path "$PREVIOUS_PATH" '{
tool:"eq",
input:{gsPath:$path},
params:{
bands:[{freq:3000,gain_db:-1.5,q:1.2}]
}
}')" | jq
Ownership and object existence are checked before any credits are reserved.
Editor
The persisted session document is the editor state. Anything the web editor can
persist can be read and written through these routes, and a valid document
round-trips through GET → PUT without changing its editable fields. Track
and clip moves, splits, trims, fades, gain, pan, mute/solo, effects, master
settings, and BPM are document edits; they do not need bespoke endpoints.
Every session route is key-authenticated and owner-scoped. PUT accepts the full
object returned by GET; server-owned id, createdAt, and updatedAt fields
are ignored on input and regenerated in the response. Invalid tracks, clips,
effects, or master state return 422 invalid-editor-session with zod issue paths.
Create a session from an asset
Use an assetId returned by the upload/finalize flow:
SESSION=$(
curl -sS -X POST "$WF_API_URL/editor/sessions" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg asset "$ASSET_ID" '{
name:"Vocal edit",
tracks:[{assetId:$asset}]
}')"
)
SESSION_ID=$(printf '%s' "$SESSION" | jq -r .id)
TRACK_ID=$(printf '%s' "$SESSION" | jq -r '.tracks[0].id')
Each asset becomes one track and one clip using the same defaults and
editor-import artifact references as the web editor.
Cut a section by editing clips
To remove source seconds 10–20 from a 30-second clip and close the timeline gap, split it into two references and omit the removed middle. Before and after:
{
"before": [
{
"id": "clip_original",
"jobId": "asset_job_id",
"srcPath": "gs://waveforge-audio.firebasestorage.app/results/user/asset/import.wav",
"peaksPath": null,
"name": "source.wav",
"sourceDuration": 30,
"start": 0,
"offset": 0,
"duration": 30,
"fadeIn": 0,
"fadeOut": 0,
"gain": 1
}
],
"after": [
{
"id": "clip_left",
"jobId": "asset_job_id",
"srcPath": "gs://waveforge-audio.firebasestorage.app/results/user/asset/import.wav",
"peaksPath": null,
"name": "source.wav",
"sourceDuration": 30,
"start": 0,
"offset": 0,
"duration": 10,
"fadeIn": 0,
"fadeOut": 0,
"gain": 1
},
{
"id": "clip_right",
"jobId": "asset_job_id",
"srcPath": "gs://waveforge-audio.firebasestorage.app/results/user/asset/import.wav",
"peaksPath": null,
"name": "source.wav",
"sourceDuration": 30,
"start": 10,
"offset": 20,
"duration": 10,
"fadeIn": 0,
"fadeOut": 0,
"gain": 1
}
]
}
Replace that track's clips array in the full session and save it:
curl -sS -X PUT "$WF_API_URL/editor/sessions/$SESSION_ID" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @session.json | jq
Set the right clip's start to 20 instead of 10 if the removed section
should remain as silence on the timeline.
Remove vocals from a track
Apply karaoke to the track's first clip (the web editor uses the selected clip, falling back to the first). The shared editor-apply core resolves the clip, validates the tool and parameters, quotes/reserves credits, and starts the normal job:
APPLY=$(
curl -sS -X POST "$WF_API_URL/editor/sessions/$SESSION_ID/apply-tool" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg track "$TRACK_ID" '{
trackId:$track,
tool:"karaoke",
params:{},
approveOverage:false
}')"
)
APPLY_JOB_ID=$(printf '%s' "$APPLY" | jq -r .jobId)
Poll /v1/jobs/$APPLY_JOB_ID. A completed multi-output job appends the
instrumental and vocals tracks and mutes (but does not delete) the original.
The job response's editorApply.status moves from pending to applied once
the idempotent session update is complete. A single audio output appends one
"<original> · <tool>" track. Billing, overage approval, plan gates,
restricted-tool policy, settlement, and refunds are the same as a web-editor
apply.
Export a rendered mix
Editor export follows the web editor's free client-render contract: render the
session with your audio engine, request a wav target, upload the rendered WAV,
then finalize it through the existing export core. For MP3/FLAC, finalize the WAV
and run the normal billed convert tool on its output.
EXPORT=$(
curl -sS -X POST "$WF_API_URL/editor/sessions/$SESSION_ID/export" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"format":"wav"}'
)
EXPORT_JOB_ID=$(printf '%s' "$EXPORT" | jq -r .jobId)
EXPORT_UPLOAD_URL=$(printf '%s' "$EXPORT" | jq -r .uploadUrl)
curl -sS -X PUT "$EXPORT_UPLOAD_URL" \
-H 'Content-Type: audio/wav' \
--upload-file ./rendered-mix.wav
curl -sS -X POST \
"$WF_API_URL/editor/sessions/$SESSION_ID/export/$EXPORT_JOB_ID/finalize" \
-H "Authorization: Bearer $WF_API_KEY" | jq
curl -sS "$WF_API_URL/jobs/$EXPORT_JOB_ID" \
-H "Authorization: Bearer $WF_API_KEY" | jq
As with asset upload, the Storage emulator takes POST rather than the
production signed URL's required PUT; that local behavior does not verify
production method signing or browser CORS.
Walkthrough 4: two-input reference mastering
Upload and finalize both source and reference tracks with the first walkthrough's asset steps, then:
export SOURCE_ASSET_ID='first-opaque-id'
export REFERENCE_ASSET_ID='second-opaque-id'
curl -sS -X POST "$WF_API_URL/jobs" \
-H "Authorization: Bearer $WF_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n \
--arg source "$SOURCE_ASSET_ID" \
--arg reference "$REFERENCE_ASSET_ID" '{
tool:"reference-master",
inputs:[{assetId:$source},{assetId:$reference}],
params:{output_format:"flac"}
}')" | jq
Both asset/path references must belong to the key owner. The source track's duration drives the shared credit quote.