Publish a release
In this tutorial you will create a one-track single entirely through the API, from an empty draft to a live release you can read back with release(upc:).
You will need:
- An API key with
catalog_read,catalog_writeandmedia_write. The Quickstart shows how to create one. - An audio file (
track.flac) and a square cover image (cover.jpg). curl, andsha256sum(on macOS,shasum -a 256).
The examples use the GraphQL tab; switch any of them to cURL to paste straight into a terminal.
1. Start a draft#
A release you author starts as a draft: private to your catalog, not
listed by releases, and editable until you publish it.
mutation {
createRelease(input: { title: "Harbour Lights", releaseType: "Single" }) {
releasePk
state
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n createRelease(input: { title: \"Harbour Lights\", releaseType: \"Single\" }) {\n releasePk\n state\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
createRelease(input: { title: "Harbour Lights", releaseType: "Single" }) {
releasePk
state
}
}
`,
}),
});
const { data, errors } = await res.json();{ "data": { "createRelease": { "releasePk": 48290, "state": "draft" } } }Keep the releasePk — every later step refers to it. We will add the UPC at
the end, to see what publishing says when something is missing.
2. Credit the artist#
Create the artist as a party, then credit them on the release as its
MainArtist:
mutation {
createParty(input: { name: "Halfway Ghosts" }) {
partyPk
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n createParty(input: { name: \"Halfway Ghosts\" }) {\n partyPk\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
createParty(input: { name: "Halfway Ghosts" }) {
partyPk
}
}
`,
}),
});
const { data, errors } = await res.json();{ "data": { "createParty": { "partyPk": 90450 } } }mutation {
creditRelease(releasePk: 48290, input: { partyPk: 90450, role: "MainArtist" }) {
contributors { party { name } role }
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n creditRelease(releasePk: 48290, input: { partyPk: 90450, role: \"MainArtist\" }) {\n contributors { party { name } role }\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
creditRelease(releasePk: 48290, input: { partyPk: 90450, role: "MainArtist" }) {
contributors { party { name } role }
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"creditRelease": {
"contributors": [{ "party": { "name": "Halfway Ghosts" }, "role": "MainArtist" }]
}
}
}3. Name the label and the rights holders#
The label is a party too — not a credit — so create it and set it with
labels. The ℗ notice (who owns the recording) and the © notice (who owns
the song) go in the same update:
mutation {
createParty(input: { name: "Lanternfish Records" }) {
partyPk
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n createParty(input: { name: \"Lanternfish Records\" }) {\n partyPk\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
createParty(input: { name: "Lanternfish Records" }) {
partyPk
}
}
`,
}),
});
const { data, errors } = await res.json();{ "data": { "createParty": { "partyPk": 90211 } } }mutation {
updateRelease(releasePk: 48290, input: {
labels: [{ partyPk: 90211 }]
plines: [{ text: "(P) 2026 Lanternfish Records", year: "2026" }]
clines: [{ text: "(C) 2026 Halfway Ghosts" }]
}) {
labels { name }
plines { value }
clines { value }
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n updateRelease(releasePk: 48290, input: {\n labels: [{ partyPk: 90211 }]\n plines: [{ text: \"(P) 2026 Lanternfish Records\", year: \"2026\" }]\n clines: [{ text: \"(C) 2026 Halfway Ghosts\" }]\n }) {\n labels { name }\n plines { value }\n clines { value }\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
updateRelease(releasePk: 48290, input: {
labels: [{ partyPk: 90211 }]
plines: [{ text: "(P) 2026 Lanternfish Records", year: "2026" }]
clines: [{ text: "(C) 2026 Halfway Ghosts" }]
}) {
labels { name }
plines { value }
clines { value }
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"updateRelease": {
"labels": [{ "name": "Lanternfish Records" }],
"plines": [{ "value": "(P) 2026 Lanternfish Records" }],
"clines": [{ "value": "(C) 2026 Halfway Ghosts" }]
}
}
}Each list replaces what was there, so sending clines: [] later removes the
notice. None of the three is required to publish — a netlabel release may
carry no notices at all — but stores display them when they are present.
4. Add the track and the cover#
Both are resources. The track is a MusicalWorkSoundRecording; the cover
is a FrontCoverImage from the ImageType vocabulary.
mutation {
track: addResource(input: {
releasePk: 48290
resourceType: "MusicalWorkSoundRecording"
title: "Harbour Lights"
isrc: "JPTO09404920"
durationSeconds: 203
}) { resourcePk state }
cover: addResource(input: {
releasePk: 48290
resourceType: "FrontCoverImage"
resourceTypeKind: "ImageType"
}) { resourcePk state }
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n track: addResource(input: {\n releasePk: 48290\n resourceType: \"MusicalWorkSoundRecording\"\n title: \"Harbour Lights\"\n isrc: \"JPTO09404920\"\n durationSeconds: 203\n }) { resourcePk state }\n cover: addResource(input: {\n releasePk: 48290\n resourceType: \"FrontCoverImage\"\n resourceTypeKind: \"ImageType\"\n }) { resourcePk state }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
track: addResource(input: {
releasePk: 48290
resourceType: "MusicalWorkSoundRecording"
title: "Harbour Lights"
isrc: "JPTO09404920"
durationSeconds: 203
}) { resourcePk state }
cover: addResource(input: {
releasePk: 48290
resourceType: "FrontCoverImage"
resourceTypeKind: "ImageType"
}) { resourcePk state }
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"track": { "resourcePk": 771340, "state": "incoming" },
"cover": { "resourcePk": 771341, "state": "incoming" }
}
}Aliases (track:, cover:) let one request call the same mutation twice.
Both resources are incoming: they exist, but have no bytes yet.
5. Upload the audio#
Files go straight to storage, never through the API. First, hash the file and measure it:
sha256sum track.flac # 9f2c4e0d…a4b3c2d track.flac
wc -c < track.flac # 48213377Ask for permission to upload exactly those bytes:
mutation {
requestUpload(
resourcePk: 771340
sha256: "9f2c4e0d4b1a7c3e8f6d5b2a1c0e9d8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d"
byteSize: 48213377
) {
alreadyPresent
uploadUrl
requiredHeaders { name value }
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n requestUpload(\n resourcePk: 771340\n sha256: \"9f2c4e0d4b1a7c3e8f6d5b2a1c0e9d8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d\"\n byteSize: 48213377\n ) {\n alreadyPresent\n uploadUrl\n requiredHeaders { name value }\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
requestUpload(
resourcePk: 771340
sha256: "9f2c4e0d4b1a7c3e8f6d5b2a1c0e9d8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d"
byteSize: 48213377
) {
alreadyPresent
uploadUrl
requiredHeaders { name value }
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"requestUpload": {
"alreadyPresent": false,
"uploadUrl": "https://media.schema.fm/sha256/9f/2c/9f2c4e0d…&X-Amz-Signature=…",
"requiredHeaders": [
{ "name": "x-amz-checksum-sha256", "value": "nyxODUsafD6PbVsqHA6djzp....Mi0=" }
]
}
}
}PUT the file to uploadUrl, sending every header in requiredHeaders
exactly as given:
curl -X PUT --upload-file track.flac \
-H "x-amz-checksum-sha256: nyxODUsafD6PbVsqHA6djzp....Mi0=" \
"https://media.schema.fm/sha256/9f/2c/9f2c4e0d…&X-Amz-Signature=…"Storage checks the bytes against the hash you declared and refuses anything else. Then tell the API the upload is done:
mutation {
completeUpload(
resourcePk: 771340
sha256: "9f2c4e0d4b1a7c3e8f6d5b2a1c0e9d8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d"
) { state }
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n completeUpload(\n resourcePk: 771340\n sha256: \"9f2c4e0d4b1a7c3e8f6d5b2a1c0e9d8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d\"\n ) { state }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
completeUpload(
resourcePk: 771340
sha256: "9f2c4e0d4b1a7c3e8f6d5b2a1c0e9d8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d"
) { state }
}
`,
}),
});
const { data, errors } = await res.json();{ "data": { "completeUpload": { "state": "stored" } } }6. Upload the cover#
Repeat step 5 for cover.jpg with the cover's resourcePk (771341).
If //SCHEMA already holds that exact image — covers are often reused across a
single and its album — requestUpload answers alreadyPresent: true and no
uploadUrl. Skip the PUT and go straight to completeUpload.
7. Try to publish#
mutation {
publishRelease(releasePk: 48290) {
published
problems
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n publishRelease(releasePk: 48290) {\n published\n problems\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
publishRelease(releasePk: 48290) {
published
problems
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"publishRelease": {
"published": false,
"problems": ["a UPC or GRid is required to publish"]
}
}
}Publishing checks everything at once and reports every problem, not just the first. A release needs a UPC or GRid, at least one resource, bytes behind every resource, and a front cover.
8. Add the UPC and publish#
mutation {
updateRelease(releasePk: 48290, input: { upc: "0196871555012" }) {
upc
}
publishRelease(releasePk: 48290) {
published
problems
release { state availableFrom }
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n updateRelease(releasePk: 48290, input: { upc: \"0196871555012\" }) {\n upc\n }\n publishRelease(releasePk: 48290) {\n published\n problems\n release { state availableFrom }\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
updateRelease(releasePk: 48290, input: { upc: "0196871555012" }) {
upc
}
publishRelease(releasePk: 48290) {
published
problems
release { state availableFrom }
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"updateRelease": { "upc": "0196871555012" },
"publishRelease": {
"published": true,
"problems": [],
"release": { "state": "active", "availableFrom": "2026-09-18T10:22:41.302118+00:00" }
}
}
}Mutations in one request run in order, so the UPC is in place before the publish checks for it.
9. Read it back#
The release is now part of your catalog, exactly like a delivered one:
query {
release(upc: "0196871555012") {
title
state
contributors { party { name } role }
labels { name }
resources { title resourceType state }
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "query {\n release(upc: \"0196871555012\") {\n title\n state\n contributors { party { name } role }\n labels { name }\n resources { title resourceType state }\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
query {
release(upc: "0196871555012") {
title
state
contributors { party { name } role }
labels { name }
resources { title resourceType state }
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"release": {
"title": "Harbour Lights",
"state": "active",
"contributors": [{ "party": { "name": "Halfway Ghosts" }, "role": "MainArtist" }],
"labels": [{ "name": "Lanternfish Records" }],
"resources": [
{ "title": "Harbour Lights", "resourceType": "MusicalWorkSoundRecording", "state": "stored" },
{ "title": null, "resourceType": "FrontCoverImage", "state": "stored" }
]
}
}
}What you have learned#
- Authoring is draft → resources → uploads → publish.
- Bytes go directly to storage, bound to the SHA-256 you declare.
- Publishing reports every problem at once, as data.
Next#
- Upload media — hashing and uploading from code.
- Release lifecycle — what happens after publishing.
- Deliver DDEX through the API — if you already have ERN files.