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-codesDrafts
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.
Collaborations: one profile link per artist
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_nameis also sent it must match that entry. Every following entry is credited as aPrimary Artistcontributor, 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 a422naming the field. Bare Spotify artist IDs andspotify:artist:URIs are accepted. - Omit any link you don’t have;
soundcloud_artist_urlandmeta_artist_urlwork on the same entries. Tracks acceptprimary_artiststoo, for releases whose tracks are credited to different artists. - Positional arrays are also accepted:
spotify_artist_urlsandapple_music_artist_urlsmap entry N to primary artist N, withnullholding 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-Kanakatakana,ja-Hirahiragana,ja-LatnLatin), and only a subset of languages has them. - A code must name the same language as the field it sits on.
ja-Kanaon a release whosemetadata_languageiskois rejected with a422. - 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_localsandartist_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 istitlein place ofnameinside atitle_localsentry. - 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).