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

MethodRouteDescription
GET/v1/toolsDeployed catalog. Anonymous callers see public tools; an owner/superuser key also sees restricted tools.
GET/v1/accountPlan, role, and available/reserved credits.
POST/v1/assetsMint an audio upload: {filename, contentType, sizeBytes?}.
POST/v1/assets/{assetId}/finalizeVerify and probe an uploaded asset, then add it to the library.
GET/v1/assetsList up to 100 library assets, newest first.
DELETE/v1/assets/{assetId}Delete a settled asset and its storage objects.
POST/v1/jobsReserve credits and immediately start a job using existing assets/results.
GET/v1/jobsList 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/sessionsCreate a session with {name, tracks?: [{assetId}]}.
GET/v1/editor/sessionsList 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-toolStart a billed tool job from a track and fold settled audio outputs back into the session.
POST/v1/editor/sessions/{sessionId}/exportMint a WAV render upload for {format:"wav"}.
POST/v1/editor/sessions/{sessionId}/export/{jobId}/finalizeVerify 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:

  1. Send a POST to resumableUrl with the exact object Content-Type and x-goog-resumable: start. Save the session URI from the Location response header. The signed URL authenticates only this initiation request.
  2. Send 8 MiB chunks to the session URI with PUT and Content-Range: bytes START-END/TOTAL. GCS returns 308 Resume Incomplete until the final chunk, then 200 or 201.
  3. After a timeout or interrupted request, send an empty PUT with Content-Range: bytes */TOTAL. A 308 response reports persisted bytes in Range: bytes=0-LAST; continue at LAST + 1. A 200/201 means the prior request completed.
  4. 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.