solutions

API v1

Documentation

Singing voice conversion for whole songs: a track and a voice sample go in, the same track in the new voice comes out, mixed and mastered.

Overview

The API is asynchronous. One conversion is one job, and a job goes through four steps:

  1. create the job and receive two upload links;
  2. upload the track and the voice sample to those links;
  3. start processing;
  4. poll the status until the job is done, then download the result.

Base URL: https://s1solutions.pro. Requests and responses are JSON. A typical track takes about two minutes to process.

MethodPathPurpose
POST/v1/jobsCreate a job
POST/v1/jobs/{id}/startStart processing
GET/v1/jobs/{id}Status and result
GET/v1/balanceBalance

Authentication

Every request to /v1/… carries your API key in a header:

http
Authorization: Bearer <api key>

Create keys in the dashboard. A key is shown once, so save it right away. A revoked key stops working immediately. Uploads and the result download use signed links and do not need the header.

POST /v1/jobs

Creates a job and returns two upload links.

Request body

FieldTypeDescription
formatstringResult format: "mp3" or "wav".
json
{"format": "mp3"}

Response

json
{
  "id": "0b6f0c1e-7c0a-4a52-9d1b-2f4d5c1f8a11",
  "upload": {
    "track_url": "https://…",
    "voice_url": "https://…"
  }
}

track_url is for the song and voice_url is for the voice sample. Both links are valid for 30 minutes. A job that is never started eventually gets the status expired.

Upload files

Send each file with an HTTP PUT to its link: raw bytes in the request body, no multipart form and no Authorization header.

bash
curl -T song.mp3  "<track_url>"
curl -T voice.wav "<voice_url>"

Each file can be up to 200 MB.

POST /v1/jobs/{id}/start

Puts an uploaded job in the queue. No request body. The response is the job object, the same as in the status request.

json
{
  "id": "0b6f0c1e-7c0a-4a52-9d1b-2f4d5c1f8a11",
  "status": "queued",
  "format": "mp3",
  "created_at": "2026-10-02T12:00:00+00:00",
  "queue_position": 0,
  "estimated_sec": 120
}

Errors

CodeReasonWhat to do
400A file is not uploadedUpload both the track and the voice sample, then call start again.
402Insufficient balanceThe balance must cover the reserve. See Billing.
503Service busyThe queue is too long. The response includes estimated_wait_sec, the estimated wait in seconds. Retry later: the job stays in awaiting_upload and nothing is reserved.

GET /v1/jobs/{id}

Returns the job object. id, status, format and created_at are always present; the other fields depend on the status.

StatusMeaningExtra fields
awaiting_uploadCreated, waiting for files and start—
queuedIn the queuequeue_position (0 means next), estimated_sec (estimated time until done)
runningBeing processedestimated_sec
doneFinishedtrack_sec (source track length), cost (amount charged, in US dollars), result_url, result_expires_in_sec
failedProcessing failed, nothing chargederror (the reason)
expiredThe job was not started in time—
json
{
  "id": "0b6f0c1e-7c0a-4a52-9d1b-2f4d5c1f8a11",
  "status": "done",
  "format": "mp3",
  "created_at": "2026-10-02T12:00:00+00:00",
  "track_sec": 212.4,
  "cost": 0.36,
  "result_url": "https://…",
  "result_expires_in_sec": 287
}

The result link works for 5 minutes. When the window closes, result_url becomes null, the file is deleted and it cannot be downloaded later. Poll every few seconds and download the result as soon as the job is done.

GET /v1/balance

Your current balance in US dollars. Amounts reserved for jobs in progress are not included.

json
{"balance": 25.0}

Billing

The price is from $0.10 per minute of the source track, billed per second. Your own rate is agreed with us and shown in your account. A 3-minute track costs $0.30. The length of the voice sample does not affect the price.

The balance, the cost field and the /v1/balance response are in US dollars. There is no online payment: the balance is topped up by arrangement. Contact us to get access and add credit.

  1. Start. The cost of a maximum-length track is reserved on your balance: 10 min × $0.10 = $1.00. If the balance cannot cover the reserve, start returns 402.
  2. Success. The actual cost is charged, based on the length of the source track, and the reserve is released.
  3. Failure. The whole reserve is released. Jobs with the status failed cost nothing.

Every movement — top-ups, reserves, releases and charges — is listed in the balance history in the dashboard.

Limits and retention

  • Track length: up to 10 minutes.
  • File size: up to 200 MB per file.
  • Upload links are valid for 30 minutes.
  • The result link works for 5 minutes. After that the result cannot be retrieved.
  • Files are not kept: the track and the voice sample are deleted when the job ends, and the result is deleted when its link expires.

Errors

Errors come back with the matching HTTP status and a JSON body with a text description.

CodeWhen
400Bad request: unknown format, or a file was not uploaded before start
401The key is missing, wrong or revoked
402The balance cannot cover the reserve
404Job not found
409The job is already started or finished
429Too many jobs created but not started
503Service busy; the body includes estimated_wait_sec

Example: curl

The full path from a new job to a finished file.

bash
export S1_API="https://s1solutions.pro"
export S1_KEY="YOUR_API_KEY"

# 1. Create a job
curl -s -X POST "$S1_API/v1/jobs" \
  -H "Authorization: Bearer $S1_KEY" \
  -H "Content-Type: application/json" \
  -d '{"format": "mp3"}'
# → {"id": "…", "upload": {"track_url": "…", "voice_url": "…"}}

# Fill these in from the response
export JOB_ID="…"
export TRACK_URL="…"
export VOICE_URL="…"

# 2. Upload the track and the voice sample (HTTP PUT, raw bytes)
curl -f -T song.mp3  "$TRACK_URL"
curl -f -T voice.wav "$VOICE_URL"

# 3. Start
curl -s -X POST "$S1_API/v1/jobs/$JOB_ID/start" \
  -H "Authorization: Bearer $S1_KEY"
# → {"id": "…", "status": "queued", "queue_position": 0, "estimated_sec": 120, …}

# 4. Poll until status is "done"
curl -s "$S1_API/v1/jobs/$JOB_ID" \
  -H "Authorization: Bearer $S1_KEY"
# → {"id": "…", "status": "done", "result_url": "…", "result_expires_in_sec": 287, …}

# 5. Download the result: the link works for 5 min
curl -f -o result.mp3 "<result_url>"

# Balance
curl -s "$S1_API/v1/balance" -H "Authorization: Bearer $S1_KEY"
# → {"balance": 25.0}

Example: Python

Needs the requests library. The function creates a job, uploads both files, starts processing, waits until the job is done and saves the result.

python
import time

import requests

API = "https://s1solutions.pro"
KEY = "YOUR_API_KEY"
HEADERS = {"Authorization": f"Bearer {KEY}"}


def convert(track_path, voice_path, out_path, fmt="mp3"):
    # 1. Create a job
    r = requests.post(f"{API}/v1/jobs", json={"format": fmt}, headers=HEADERS, timeout=30)
    r.raise_for_status()
    job = r.json()
    job_id = job["id"]

    # 2. Upload the files: HTTP PUT, raw bytes
    uploads = (
        (job["upload"]["track_url"], track_path),
        (job["upload"]["voice_url"], voice_path),
    )
    for url, path in uploads:
        with open(path, "rb") as f:
            requests.put(url, data=f, timeout=600).raise_for_status()

    # 3. Start; if the service is busy (503), wait and retry
    while True:
        r = requests.post(f"{API}/v1/jobs/{job_id}/start", headers=HEADERS, timeout=30)
        if r.status_code != 503:
            break
        time.sleep(30)
    if r.status_code == 402:
        raise RuntimeError("Insufficient balance")
    r.raise_for_status()

    # 4. Wait until the job is done
    while True:
        r = requests.get(f"{API}/v1/jobs/{job_id}", headers=HEADERS, timeout=30)
        r.raise_for_status()
        job = r.json()
        if job["status"] == "done":
            break
        if job["status"] in ("failed", "expired"):
            raise RuntimeError(f"Job {job_id}: {job.get('error') or job['status']}")
        time.sleep(5)

    # 5. Download the result right away: the link works for 5 min
    with requests.get(job["result_url"], stream=True, timeout=600) as r:
        r.raise_for_status()
        with open(out_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=1 << 20):
                f.write(chunk)

    return job


if __name__ == "__main__":
    job = convert("song.mp3", "voice.wav", "result.mp3")
    print(f"Done: {job['track_sec']} s, charged ${job['cost']}")

    balance = requests.get(f"{API}/v1/balance", headers=HEADERS, timeout=30).json()["balance"]
    print(f"Balance: ${balance}")