REST APIReleases

Releases

Once your assets are uploaded, you can save a draft and submit a release for distribution. All endpoints require authentication.

Read the rules first

Before building payloads, read GET /v1/metadata-rules (artist-name policy, title casing, writers, UPC, AI disclosure, cover songs) and GET /v1/release-schema (authoritative required fields and an example payload). Use GET /v1/distribution-stores to fetch supported DSP IDs before setting release.distribution_store_ids, and GET /v1/language-script-codes for the script codes accepted on non-Latin metadata. The platform enforces these rules at submission, so reading them first saves you rejected requests.

curl https://once.app/v1/metadata-rules
curl -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" https://once.app/v1/release-schema
curl https://once.app/v1/distribution-stores
curl -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" https://once.app/v1/language-script-codes

Drafts

curl -X POST https://once.app/v1/drafts \
  -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "release": { ... }, "tracks": [ ... ], "mode": "delta" }'

POST /v1/drafts upserts a work-in-progress release snapshot. Body: { "release"?, "tracks"?, "track_patches"?, "upload_requests"?, "status"?, "mode"?: "delta" | "replace", "release_id"?, "conversation_id"? }. Use mode: "delta" to patch an existing draft or mode: "replace" to overwrite it.

To update an existing draft, pass its release_id (from GET /v1/releases) or the conversation_id of the chat it belongs to. When neither is provided, ONCE creates a new draft release from the payload and returns its id. The draft appears in the app immediately, and the response includes releaseCreated: true. Pass the returned releaseId as release_id on follow-up calls so edits land on the same draft instead of creating a new one.

{
  "releaseId": "6f0f0a4e-...",
  "releaseCreated": true,
  "snapshot": { "revision": 1, "snapshotId": "...", "status": "collecting" },
  "latest": { "payload": { ... }, "tracks": [ ... ] }
}

Price a release before submitting

curl -X POST https://once.app/v1/releases/quote \
  -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "tracks": [ ... ] }'

POST /v1/releases/quote prices exactly the tracks you pass, using the same code that debits on submit, and reports them against the live balance. Nothing is created or charged.

{
  "cost": { "trackCredits": 2, "tokenOffsetCredits": 0, "totalCredits": 2, "totalUsd": 2 },
  "balance": { "available": 2, "required": 2, "covered": true, "shortfallCredits": 0 },
  "submittable": true,
  "blockers": []
}

Submit when balance.covered is true; otherwise buy balance.shortfallCredits first. Do not work the cost out client-side to decide whether to submit. Whether a track is billable depends on it carrying a readable audio url, and whether it is AI depends on the contains_ai / containsAi / ai_generation_credits fields on the payload you send. An estimate that lands a credit above what ONCE charges disables your own submit button against a balance that was actually sufficient, and since nothing reaches ONCE there is no error anywhere to explain it.

submittable also covers what credits cannot fix: a track with no readable audio is unbillable and rejected on submit, so it appears in blockers while covered stays true.

Submit a release

curl -X POST https://once.app/v1/releases \
  -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "release": { ... }, "tracks": [ ... ] }'

POST /v1/releases submits the release for distribution and returns 201. Build the body from GET /v1/release-schema, attaching the fileUrls returned by your uploads and cover-art calls. Submission debits credits from your account (1 credit per human song, 2 per AI-detected song). Releases are distributed to all supported stores unless release.distribution_store_ids selects specific DSPs.

New clients should provide DSP selection, record label, C/P copyright credits, track type, and Producer/Engineer role credits explicitly. Existing v1 clients that omit those expanded fields continue to work with ONCE’s server defaults.

{
  "release": {
    "title": "My Release",
    "primary_artist_name": "Artist Name",
    "genre": "Pop",
    "sub_genre": "Dance Pop",
    "release_date": "2026-02-01",
    "label": "Artist Name Records",
    "audio_language": "en",
    "metadata_language": "en",
    "distribution_store_ids": [1, 9, 13, 319],
    "pline_year": "2026",
    "pline_owner": "Artist Name Records",
    "cline_year": "2026",
    "cline_owner": "Artist Name Records",
    "cover_art_file_url": "/api/files/cover-art/USER_ID/cover.jpg",
    "contributors": [
      { "name": "First Producer", "role": "Producer" },
      { "name": "First Engineer", "role": "Engineer" }
    ]
  },
  "tracks": [
    {
      "title": "My Release",
      "primary_artist_name": "Artist Name",
      "audio_file_url": "/api/files/audio/USER_ID/track.wav",
      "explicit_flag": false,
      "track_type": "original",
      "language": "en",
      "preview_start_seconds": 30,
      "pline_year": "2026",
      "pline_owner": "Artist Name Records",
      "cline_year": "2026",
      "cline_owner": "Artist Name Records",
      "writers": [{ "name": "First Last" }],
      "contributors": [
        { "name": "First Producer", "role": "Producer" },
        { "name": "First Engineer", "role": "Engineer" }
      ]
    }
  ]
}

For DSP selection, pass a non-empty distribution_store_ids array using store IDs from GET /v1/distribution-stores, or pass null to explicitly choose all supported stores. Do not send an empty array.

spotify_artist_url and apple_music_artist_url map the release to one artist profile, meaning the main primary artist. For collaborations, send every primary artist in release.primary_artists, in credit order, each with their own profile links, so each artist maps to their existing store profile instead of being matched by name:

{
  "release": {
    "title": "Collab",
    "primary_artist_name": "FussyCraft",
    "primary_artists": [
      {
        "name": "FussyCraft",
        "spotify_artist_url": "https://open.spotify.com/artist/SPOTIFY_ARTIST_ID",
        "apple_music_artist_url": "https://music.apple.com/us/artist/fussycraft/1234567890"
      },
      {
        "name": "Michal - Mad Max",
        "spotify_artist_url": "https://open.spotify.com/artist/SPOTIFY_ARTIST_ID",
        "apple_music_artist_url": "https://music.apple.com/us/artist/michal-mad-max/987654321"
      }
    ]
  }
}
  • The first entry is the main primary artist; when primary_artist_name is also sent it must match that entry. Every following entry is credited as a Primary Artist contributor, in order.
  • Links must be artist profile URLs (https://open.spotify.com/artist/…, https://music.apple.com/<storefront>/artist/<slug>/<id>). Album, song, and playlist links are rejected with a 422 naming the field. Bare Spotify artist IDs and spotify:artist: URIs are accepted.
  • Omit any link you don’t have; soundcloud_artist_url and meta_artist_url work on the same entries. Tracks accept primary_artists too, for releases whose tracks are credited to different artists.
  • Positional arrays are also accepted: spotify_artist_urls and apple_music_artist_urls map entry N to primary artist N, with null holding a position. Sending more links than primary artists is rejected.

Nothing changes for single-artist releases: keep using the flat spotify_artist_url / apple_music_artist_url fields. Drafts (POST /v1/drafts) accept the same fields, and GET /v1/releases/:id returns the stored per-artist links as release.artist_profiles.

For remixes, do not send remix as track_type. Use title_version: "Remix" and include a Remixer contributor when applicable.

Partners can attach the X-Once-Provenance header (e.g. AIMD) so the release is attributed to their surface.

After submission, poll release status to follow delivery.

Non-Latin metadata: scripts and localized titles

A Japanese release delivered only in kanji is only findable by listeners searching in kanji. Two optional fields fix that, on the release, on each track, and on every localization entry.

language_and_script_code declares the writing system the metadata you are sending is written in:

{
  "release": {
    "title": "曲名",
    "metadata_language": "ja",
    "language_and_script_code": "ja-Kana"
  },
  "tracks": [
    { "title": "曲名", "language": "ja", "language_and_script_code": "ja-Kana" }
  ]
}

title_locals carries the same title in another script, and artist_locals does the same for the artist name. Each entry takes a language, an optional script code, and an optional phonetic reading:

{
  "release": {
    "title": "曲名",
    "metadata_language": "ja",
    "language_and_script_code": "ja-Kana",
    "title_locals": [
      { "name": "Kyokumei", "language": "ja", "language_and_script_code": "ja-Latn" }
    ],
    "artist_locals": [
      { "name": "Artist Name", "language": "ja", "language_and_script_code": "ja-Latn" }
    ]
  }
}
  • Valid codes come from GET /v1/language-script-codes. They are a language plus an ISO 15924 script (ja-Kana katakana, ja-Hira hiragana, ja-Latn Latin), and only a subset of languages has them.
  • A code must name the same language as the field it sits on. ja-Kana on a release whose metadata_language is ko is rejected with a 422.
  • Codes ONCE cannot confirm against that language are dropped at submission rather than failing the release, so the rest of the metadata still delivers.
  • One entry per language in title_locals and artist_locals — that is a store-side rule, not ours. You cannot send hiragana and katakana and Latin as three entries for Japanese: put the main script on the release/track and the second script in one locals entry. Extra entries for a language already present are ignored.
  • The Revelator spellings are accepted as aliases (languageAndScriptCode, titleLocals, releasesLocals, tracksLocals), as is title in place of name inside a title_locals entry.
  • Drafts (POST /v1/drafts) accept all of it. Localized title versions are not delivered.

List releases

curl -H "Authorization: Bearer $ONCE_ACCESS_TOKEN" \
  'https://once.app/v1/releases?limit=100'

GET /v1/releases returns recent releases for the authenticated user (limit-only paging). GET /v1/releases/:id returns the merged metadata for a single release (its draft snapshot or latest merged data).