SongCleaner API quickstart

Submit a track, poll it, download the clean audio. The full reference is the OpenAPI schema.

Before you start

You need two secrets, both issued with your account: an API key (sc_live_...), which authenticates every request, and a webhook signing secret (whsec_...), which you only need if you want events pushed to you.

Store both the moment you have them. We keep only a hash of the API key, so it genuinely cannot be shown to you again: losing it means creating a new one. The signing secret we do hold, because signing your deliveries requires it, but no v1 API endpoint returns it.

You create your API key yourself, so that a live credential never travels by email. When your account is set up we switch key creation on for it: sign in, open API settings, confirm it is you, and choose Create API key. It is shown on that page and nowhere else, so paste it straight into your secret store. Ask us if you ever need a replacement. Your signing secret is always in your dashboard: sign in, open API settings, confirm it is you, and choose Reveal signing secret. Email us only if you cannot sign in, or cannot confirm your identity there.

Access, trial and price. API accounts are set up by us, so there is no self-service sign-up for them, and the consumer plans on our pricing page do not apply: an API account's rate, and any trial tracks, are agreed with you and recorded on the account. Email hello@songcleaner.com to ask for access, or to ask what yours are.

Once you have a key, GET /api/v1/usage is step zero: it shows this month's tracks and what they cost in cents, everything not yet invoiced, and trial_tracks_remaining, the completed tracks still free on your account. Check it before you submit anything, and again afterwards to see what a track cost.

Every request carries the key as a bearer token:

Authorization: Bearer sc_live_your_key_here

Missing, malformed, unknown and revoked keys all fail the same way, with 401 invalid_api_key, and carry WWW-Authenticate: Bearer.

1. Submit a track

Post the audio as multipart form data. You get a 202 straight back with the track id; cleaning runs asynchronously. A worker can start while that response is still in flight, so a webhook may reach you before your HTTP call returns: make the handler tolerate a track id you have not stored yet.

curl -X POST https://songcleaner.com/api/v1/tracks \
  -H "Authorization: Bearer $SONGCLEANER_KEY" \
  -H "Idempotency-Key: your-own-unique-id" \
  -F "file=@song.mp3" \
  -F "reference=your-internal-id" \
  -F "webhook_url=https://your.app/hooks/songcleaner"

reference is yours: we echo it back on the track, in listings and in webhook payloads, so you never have to store our id against yours. webhook_url is optional; polling works just as well. Idempotency-Key is strongly recommended: repeat the same request with the same key inside 24 hours and you get the original track back instead of paying for the work twice. The same key with a different file answers idempotency_conflict.

2. Poll until it is complete

curl https://songcleaner.com/api/v1/tracks/trk_XXXXXXXX \
  -H "Authorization: Bearer $SONGCLEANER_KEY"

status moves through processing and rendering to complete, or to failed. Poll every 30 seconds or so; a typical track finishes in a few minutes.

The full vocabulary, which is also what ?status= accepts when listing, is processing, analyzed, rendering, complete, failed. analyzed belongs to review mode, which is not part of this version: it is a valid filter that matches nothing.

complete means the audio is durably stored, not merely rendered, so the artifacts in that response are ready to download. A track is never billed before it reaches that point.

3. Download the results

Each artifact endpoint redirects to a short-lived signed URL, so follow redirects and do not store the URL you land on.

curl -L -o clean.mp3 \
  https://songcleaner.com/api/v1/tracks/trk_XXXXXXXX/download/clean_mp3 \
  -H "Authorization: Bearer $SONGCLEANER_KEY"

The kinds are clean_mp3, clean_flac, instrumental.

Three ways this says no, and they mean different things. A track still processing answers not_ready. A track whose deletion has been claimed answers gone. A track already deleted at the end of its retention window is simply not_found, the same as an id that never existed. Age alone does not decide: a track past the window that the sweep has not reached yet still downloads normally.

The words we transcribed are at GET /tracks/{id}/lyrics: every word in order, with its start and end in seconds and whether it was selected for censorship. They are ready from the moment rendering starts, which is a few minutes in, so they answer not_ready until then and gone once a deletion has been claimed. A failed track serves what analysis had selected when it failed; one that failed before analysis finished has no lyrics and stays not_ready. The times are the word's timing in the transcript, not the exact muted span: the clean version mutes each selected word plus about 50 ms on either side.

4. Delete a track (optional)

You do not have to wait for the retention window. Delete a track and its audio whenever you like:

curl -X DELETE https://songcleaner.com/api/v1/tracks/trk_9dQ4vK2mR7xT1sPzYb3WgA \
  -H "Authorization: Bearer sc_live_your_key_here"

The decision is immediate and cannot be undone. New download requests are refused at once, though a download link we handed out before the deletion keeps working until it expires, at most 15 minutes later. If the track is still being processed, that work is stopped rather than finished, so you can pull back audio that should never have been sent. Every live copy of the audio is removed within 48 hours (usually the next day, one more if the track was mid-processing when you deleted it), which is why the answer is 202 and not 204.

Backups are the exception, and they have their own, longer deadline. A deleted track is never backed up again, and any backup copy already made expires within 30 days, the same limit that applies to every backup we keep.

It is safe to retry: deleting a track that is already being deleted answers the same way, with the same deletion_claimed_at. Once the audio is gone the id stops resolving and you get not_found.

Billing is unaffected. A track that completed was already priced and stays on your invoice; deleting the audio does not delete what it cost. A track deleted before it completed was never priced. To have a whole account erased rather than one track, email hello@songcleaner.com.

Webhooks

If you set webhook_url, we POST one event when the track reaches a terminal state: track.completed or track.failed. Push is a convenience: everything it tells you is also available by polling GET /tracks/{id}.

GET /tracks/{id}/events is the delivery log for a track you gave a webhook_url: what we sent, how many attempts it took, and what your endpoint answered. It is a diagnostic, not a way to follow progress. A track submitted without a webhook URL has no events at any stage, however far it gets.

A track can produce both events. A track that has failed can still be completed afterwards, when a parallel upload finishes storing every artifact: durable success outranks an earlier failure, because the audio really is there. (An ordinary storage outage does not fail your track at all. It stays rendering and we keep retrying, so no failure event is raised in the first place.) We stop sending a failure the moment completion is recorded, but an attempt already on the wire cannot be recalled, so you may receive track.failed after track.completed. Treat completion as authoritative: a failure for a track you have already seen complete is stale, whatever order it arrives in. Deduplicating on event id does not cover this, because the two are genuinely different events.

{
  "id": "evt_XXXXXXXX",
  "type": "track.completed",
  "created": 1765972800,
  "data": {
    "track_id": "trk_XXXXXXXX",
    "reference": "your-internal-id",
    "status": "complete"
  }
}

Verify the signature

Every delivery carries X-SongCleaner-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over "{t}.{raw_body}" keyed with your signing secret. Sign against t from the header, never the payload's created: the header is regenerated for every attempt, so on a retry the two are hours apart and the body is unchanged. The construction is Stripe's, so if you already verify Stripe webhooks the same helper works here with a different secret and header name. One difference worth knowing: that scheme allows a header to carry several v1 signatures, and we currently always send exactly one. Check every v1 present rather than the first or the last, as the snippet below does, and a future change to that cannot break you.

Verify against the raw body. Parsing the JSON and re-serialising it changes the bytes and the signature will not match.

"""Verify a SongCleaner webhook signature. Copy this into your app.

Shown verbatim on the docs page and exercised by
`test_api_v1_docs.WebhookSnippetTests` against the same frozen vector that
pins the implementation, so the two cannot drift.
"""
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(secret, body, header, tolerance=TOLERANCE_SECONDS):
    """True if `header` signs `body` with `secret` and is recent.

    `body` is the RAW request body, and bytes are what you actually have:
    Django gives you `request.body`, Flask `request.get_data()`. Do not
    parse the JSON and re-serialise it, and do not str() the bytes; either
    changes what gets signed and the signature will not match.

    `secret` is the signing secret issued with your API key, and it starts
    with `whsec_`. It is not your API key.

    Never raises on a malformed header. Everything a caller cannot trust
    is untrusted here too: a header is either valid or it is False.
    """
    if isinstance(body, str):
        body = body.encode()
    if isinstance(secret, str):
        secret = secret.encode()

    # An unsigned request has no header at all, and `request.headers.get()`
    # hands you None for it. Reaching .split() on that is an AttributeError
    # in the middle of someone's webhook handler (review, 2026-09-08).
    if not header:
        return False

    # EVERY v1, not the first or the last. The scheme allows a header to
    # carry more than one signature, which is how a sender can sign with
    # two secrets at once so a receiver can be updated without a flag
    # day. Parsing this into a dict keeps whichever came last and checks
    # only that one, which is a verifier that quietly depends on the
    # order a sender happened to use.
    timestamp, signatures = None, []
    for part in header.split(','):
        if '=' not in part:
            continue
        name, value = part.split('=', 1)
        if name == 't':
            timestamp = value
        elif name == 'v1':
            signatures.append(value)
    if not timestamp or not signatures:
        return False

    # Reject anything outside the window before checking the signature, so
    # a captured request cannot be replayed at you tomorrow. A timestamp
    # that is not a number, or is absurdly large, is malformed, not old.
    try:
        if abs(time.time() - int(timestamp)) > tolerance:
            return False
    except (ValueError, OverflowError):
        return False

    expected = hmac.new(
        secret, timestamp.encode() + b'.' + body, hashlib.sha256,
    ).hexdigest()
    # compare_digest, never ==: a plain comparison returns early on the
    # first wrong byte and leaks the signature one character at a time. It
    # rejects non-ASCII arguments by raising, so a hostile header cannot
    # turn a failed check into a 500 on your side.
    #
    # Every candidate is compared even after one matches. Stopping early
    # would make the time taken depend on WHICH signature matched, and
    # that is the leak compare_digest exists to avoid.
    matched = False
    for signature in signatures:
        try:
            if hmac.compare_digest(expected, signature):
                matched = True
        except TypeError:
            continue
    return matched

Rotating the signing secret

Rotate it yourself from your dashboard: API settings, then Rotate signing secret. We generate the replacement and show it to you straight away. If you lose that page before copying it, use Reveal signing secret to see it again; do not rotate a second time. Email hello@songcleaner.com only if you cannot sign in or confirm your identity.

If the secret has leaked, stop accepting it yourself, now. Do not wait for us, and do not accept both secrets as described below. Anyone holding the leaked secret can sign a fresh timestamp, so a receiver that still accepts it accepts their forgeries no matter what we change at our end. Rejecting it also rejects our own deliveries until you have the new secret; that is the right trade, and the last paragraph of this section is how you recover what you missed. Then rotate it from API settings in your dashboard, or tell us if you cannot.

For a planned rotation, know the exact cutover: we sign with one secret at a time. The new secret applies to every attempt we prepare after it is set, including retries of events you have not received yet. An attempt already in flight at that moment can still arrive signed with the old one. So for the changeover, and only for a planned one, accept either secret, then drop the old one. Against the recipe above that is computing the expected signature for each of your two candidates and accepting a constant-time match on either.

A delivery your endpoint rejects is retried, but only while that event has attempts left. The schedule below counts from each event's own first attempt, not from the rotation, so an event that was already late may have minutes left rather than a day. Anything that runs out is not lost: the track's current state is always at GET /tracks/{track_id}, its delivery history at /events, and we can re-send deliveries that died on request.

Retries

Reply 2xx within 8 seconds. That is the whole network budget for an attempt, DNS and TLS included, so a reply arriving at nine seconds is a failure however healthy it looks. Anything else is a failure and we retry, up to 8 attempts in total, at these offsets from the first one:

AttemptSent
1immediately
21 minute after the first attempt
35 minutes after the first attempt
430 minutes after the first attempt
52 hours after the first attempt
66 hours after the first attempt
712 hours after the first attempt
824 hours after the first attempt

Make your handler idempotent: a delivery we could not confirm gets sent again, so you can see the same event id twice.

Limits

Upload size200 MB per file, MP3, FLAC, WAV or MP4
Rate limit60 requests per minute per account, across all of its keys, on every endpoint
In flightNew submissions are refused once the account has 20 or more tracks processing, across all of its keys. This is an admission threshold, not a hard ceiling: submissions made at the same moment on different keys can take an account over it, by up to one track for each additional key. A replay of an already accepted submission is never refused for this.
Page sizeUp to 100 tracks per page when listing
IdempotencyKeys are honoured for 24 hours
RetentionTracks and their audio are deleted 180 days after completion. Download what you need inside that window.

Error codes

Every error response the API produces is the same envelope, with one exception worth handling: an upload larger than the reverse proxy will accept is rejected before it reaches us, and that 413 carries an HTML body. If a 413 will not parse as JSON, treat it as "upload too large". Files within the 200 MB limit never hit it. Branch on code, which is stable. The message is for humans and may be reworded without notice.

{"error": {"code": "too_many_in_flight", "message": "This account already has 20 or more tracks processing, across all of its keys. Submit again when some have finished."}}
CodeHTTPWhen
bad_request400Malformed request.
invalid_api_key401Missing, malformed, unknown, revoked or deactivated key. All four fail identically, so this is not a key-existence oracle.
forbidden403The key is valid but not for this.
not_found404No such track for this account. A track belonging to someone else answers exactly the same way, and so does one already deleted.
method_not_allowed405Wrong verb for that path.
idempotency_conflict409That Idempotency-Key was already used with a different payload.
not_ready409The track has not reached that stage yet: downloads need it complete, lyrics need analysis finished.
deletion_needs_review409The track cannot be deleted automatically: our own record of its stored objects is incomplete, and deleting on top of that would leave audio behind with nothing naming it. A human completes it.
gone410The track is being deleted and its audio is no longer available.
file_too_large413The upload is over the size limit.
file_required422No file part in the multipart body.
unsupported_media_type422Not an audio file we accept. MP3, FLAC, WAV and MP4 only.
no_audio_stream422The container carries no audio stream.
invalid_artifact_kind422Unknown download kind. The kinds are clean_mp3, clean_flac and instrumental.
invalid_webhook_url422webhook_url is not an HTTPS URL we will deliver to.
source_url_not_supported422Submit the audio itself; we do not fetch from a URL.
review_mode_not_available422Review mode is not part of v1. Tracks are cleaned automatically.
validation_error422A parameter is wrong: an unknown status filter, a bad page size.
rate_limited429Too many requests this minute. Retry-After says when to come back.
too_many_in_flight429The account is at or over its in-flight threshold, counting every key. New submissions are refused until tracks finish; a replay of an already accepted submission still returns the original.
internal_error500Our fault. Retry, then tell us.

Full request and response detail is in the OpenAPI schema.