# Catalogs and tenancy > Why every credential is bound to one catalog, why no operation takes a catalog id, and why your data is never mixed with anyone else's. Source: https://docs.schema.fm/explanation/catalogs-and-tenancy/ ## Organizations and catalogs An **organization** is a //SCHEMA customer. It owns the people who sign in, the API keys, the SFTP endpoint and its delivery accounts. A **catalog** is a body of music data that belongs to one organization: releases, tracks, parties, credits, deliveries. Most organizations have one; some keep a second for testing. The catalog is the isolation boundary — no query can see across it. ## The catalog comes from the credential Look through the [reference](https://docs.schema.fm/reference/index.md) and you will not find an argument that selects a catalog. That is deliberate. An API key is issued for one catalog; a signed-in user works in the catalog they chose. Every operation acts on that catalog and nothing else. The alternative — a `catalogId` argument checked against the caller's permissions — makes isolation depend on every single operation remembering to check. With the catalog taken from the credential, there is nothing to remember and nothing to get wrong: a request cannot even *name* someone else's catalog. It also shapes how "not found" behaves. Asking for a UPC that exists in another catalog returns exactly what asking for a UPC that exists nowhere returns — `null` — so a key cannot be used to discover what other customers have. ## Your data is yours Everything a label or you assert about music — releases, titles, credits, parties, labels, copyright notices — is stored **per catalog**. If two catalogs each receive the same album, each has its own copy of the metadata. That can look wasteful, but the alternative is worse. With one shared record, one customer's corrections would change another customer's catalog, and a mistyped identifier in one delivery could attach data to someone else's release. Keeping assertions per catalog means your catalog changes only because of deliveries into it and edits you make. ## Parties are per catalog too A party — an artist, a label — is scoped to the catalog, and within it the same party is reused across releases: the same label on forty releases is one `partyPk`. Parties with a DPID are matched by it; parties without one are never merged by name alone, because two different artists can share a name. --- # Content-addressed media > Why files are identified by their SHA-256, why uploads declare a hash first, and why a file //SCHEMA has seen before uploads instantly. Source: https://docs.schema.fm/explanation/content-addressed-media/ ## A file's name is its hash When //SCHEMA stores a file, its location is derived from the SHA-256 of its contents — not from its filename, not from the release it belongs to, not from the catalog. The same bytes always end up in the same place, and different bytes never collide. ## Deduplication for free Labels reuse files constantly: one cover image for the single, the EP and the album; one master on the original release and three compilations. Since identical bytes have an identical address, each is stored once. This is why [requestUpload](https://docs.schema.fm/reference/uploads/#mutation-requestUpload) can answer `alreadyPresent: true`. If //SCHEMA holds those bytes — from any earlier delivery — there is nothing to upload, and the resource is backed immediately. ## Why you declare the hash before uploading The address is the hash, so //SCHEMA has to know the hash before it can say where the bytes go — and it wants to know that without receiving the bytes itself. So the client hashes the file and declares the digest, and //SCHEMA signs an upload URL for exactly that address. The signature includes the checksum. Storage recomputes the digest of what you send and refuses a mismatch. This matters more than it first appears: a file stored under the wrong address would be wrong for every resource that references that address. Binding the upload to the declared hash makes that impossible. Then [completeUpload](https://docs.schema.fm/reference/uploads/#mutation-completeUpload) asks storage whether the object is really there before recording anything. A resource can never point at bytes that do not exist. ## Why media is never public The storage itself is private; the only way to read a file is a short-lived signed URL from [Resource.url](https://docs.schema.fm/reference/resources/#Resource). And a file's address carries no catalog — so knowing an address is not permission to read it. Before //SCHEMA signs a URL, it has already established that the resource belongs to your catalog, and it requires the `media_read` scope on top of `catalog_read`, because reading metadata and downloading masters are different grants. ## Files are not deleted with resources Removing a resource from a release does not delete its file: the same bytes may back other resources. --- # DDEX vocabularies > What DDEX allowed-value sets are, why //SCHEMA accepts values outside them, and how to offer the right values in your own UI. Source: https://docs.schema.fm/explanation/ddex-vocabularies/ ## Allowed-value sets DDEX publishes **allowed-value sets** (AVS): the permitted values for fields such as a contributor's role (`MainArtist`, `Producer`), a resource's type (`MusicalWorkSoundRecording`, `FrontCoverImage`), a territory (`US`, `DE`, `Worldwide`) or a commercial model (`SubscriptionModel`). Each set is a **kind**; a value is only meaningful within its kind — `DJ` is both a country code and a contributor role. This is why authoring inputs ask for both: `role` and `roleKind`, `resourceType` and `resourceTypeKind`. ## Accepted, not rejected A delivery can contain a value DDEX does not define — a misspelling, a sender's private extension, or a value from a newer DDEX version. //SCHEMA accepts it anyway, and records it as observed rather than DDEX-defined. Refusing it would mean refusing the delivery, over a detail that can be fixed later by a person or a subsequent ERN. DDEX's own schemas are not always free of defects either, so treating them as a gate could reject valid music for invalid reasons. Delivery never fails on vocabulary. [vocabValues](https://docs.schema.fm/reference/vocabularies/#query-vocabValues) shows the difference: `ddexDefined: false` marks a value that arrived in a delivery but is not in DDEX's set. ## Stored exactly as sent Values are never normalized: no case-folding, no trimming of accents, no mapping of synonyms. `MainArtist` stays `MainArtist`; a sender who wrote `mainArtist` is kept as `mainArtist`. //SCHEMA must be able to regenerate what it received. Storing verbatim does not mean matching strictly. Normalization happens in the comparison instead, so what you send is preserved and what you search for still finds it: - **Vocabulary filters** — `role`, `type` and their kin — compare case-insensitively, so you do not need to match a sender's capitalisation. DDEX vocabulary values are ASCII tokens, so case is the only difference that arises. - **Free-text search** — `q` on `releases`, matching titles, display artists and labels — folds case *and* accents. `sinead` finds `Sinéad`, and `Sinéad` finds `sinead`; neither side has to guess how the other typed it. Two things free-text search deliberately does not fold, because both change what a title *is* rather than how it is spelled: - **Typographic apostrophes.** `O'Connor` and `O'Connor` differ by a real character (`U+0027` against `U+2019`) and are not matched to each other. If your senders are inconsistent here, search for the distinctive part of the name — `connor` — rather than the whole string. - **Misspellings.** Matching is substring, not fuzzy: `O'Conner` will not find `O'Connor`. ## Offering values in your UI 1. Ask [vocabKindIsClosed](https://docs.schema.fm/reference/vocabularies/#query-vocabKindIsClosed) whether a kind is a fixed list. Most are; some, such as genre, are open. 2. Fetch the values once with [vocabValues](https://docs.schema.fm/reference/vocabularies/#query-vocabValues) — the limit is high enough for the largest set — and cache them. They change only when DDEX publishes a new revision. 3. Offer a picker for closed kinds, and a picker plus free text for open ones. ```request query: | query Roles { closed: vocabKindIsClosed(kind: "ResourceContributorRole") values: vocabValues(kind: "ResourceContributorRole", limit: 1000) { value ddexDefined } } ``` ## Useful kinds | Kind | Used by | |---|---| | `DisplayArtistRole` | Release-level billing credits — `MainArtist`, `FeaturedArtist` | | `ResourceContributorRole` | Track credits — `Producer`, `Mixer`, `AssociatedPerformer` | | `SoundRecordingType` | `resourceType` for audio | | `ImageType` | `resourceType` for artwork — `FrontCoverImage` | | `ReleaseType` | `releaseType` — `Album`, `Single`, `EP` | | `CurrentTerritoryCode` | Territories | --- # Out-of-order delivery > Why the message and its media can arrive in any order, how //SCHEMA matches them up, and why a resource can exist before its file. Source: https://docs.schema.fm/explanation/out-of-order-delivery/ ## The problem A DDEX delivery is a message — the ERN XML describing a release — plus the files it references. In practice they arrive in whatever order the sender's tooling uploads them: the audio first and the XML last, the XML first and the artwork an hour later, or a retry that resends half of it. A system that required the message first would reject real deliveries. One that required the files first could never tell a slow delivery from a broken one. //SCHEMA accepts both orders and converges on the same result. ## Expectations and arrivals When a **message** arrives, //SCHEMA records every file it references as an *expectation*: this delivery expects `resources/A1_Signal_Fire.flac`, with this checksum. The resources it describes exist from that moment, in state `incoming`. When a **file** arrives, //SCHEMA stores it and records the *arrival*: this delivery received `resources/A1_Signal_Fire.flac`, with these bytes. Whichever comes second completes the match. An expectation meeting its arrival attaches the bytes to the resource and moves it to `stored`. When the last expectation of a release is met, and it has cover art, the release goes `active`. ## The delivery namespace A file and an expectation match only if they belong to the same delivery. That is what the **delivery namespace** is: over SFTP it is the directory the delivery was uploaded into; through the API it is the value [deliverMessage](https://docs.schema.fm/reference/deliveries/#mutation-deliverMessage) returns. It is compared exactly. A file delivered under a different namespace, or with a path spelled differently from the message's `` element, will not match — it stays an `orphan`. That is the most common reason a delivery sits in [blockedReleases](https://docs.schema.fm/reference/deliveries/#query-blockedReleases). Namespaces are also scoped to the delivery account over SFTP, so two partners who happen to choose the same directory name never match each other's files. ## Why a resource can exist before its bytes Because the message can come first, a resource can be fully described — ISRC, title, credits, duration — with no file behind it. That is the `incoming` state, and it is normal, not an error. `url` is `null` until the file lands. ## Nothing is lost by arriving early A file that arrives before its message is kept, not rejected. It is an `orphan` only in the sense that nothing is waiting for it *yet*; when the message arrives, the match happens then. A periodic sweep also re-checks anything both sides missed, so a delivery completes even if its last piece arrived at an unlucky moment. ## Watching it converge A [webhook](https://docs.schema.fm/guides/receiving-webhooks.md) shows the choreography as it happens. `message.processed` arrives when the XML has been read — with `status: "partial"` and a `missingFileCount` when files it declared have not arrived yet. `message.completed` follows when the last of them lands and is matched. Neither fires for a replayed message. ## Replays are harmless Senders retry. //SCHEMA treats a repeated message as a duplicate and changes nothing, and a repeated file at the same path as a duplicate too. Delivering the same thing twice converges on the same state as delivering it once, which is what makes it safe to retry a failed upload without first finding out what succeeded. ## Checksums If a message declares a checksum for a file and the file that arrives does not match, the resource is marked `failed` and the release is not promoted. Delivering the correct file clears it; no one has to intervene. --- # Release lifecycle > The four release states — draft, pending, active, withdrawn — how a release moves between them, and which queries see it in each. Source: https://docs.schema.fm/explanation/release-lifecycle/ ## The states | State | How it gets there | Seen by | |---|---|---| | `draft` | Created through the API with [createRelease](https://docs.schema.fm/reference/releases/#mutation-createRelease) | [draftRelease](https://docs.schema.fm/reference/releases/#query-draftRelease), [draftReleases](https://docs.schema.fm/reference/releases/#query-draftReleases) — `catalog_write` only | | `pending` | Delivered, but not ready to go live | [blockedReleases](https://docs.schema.fm/reference/deliveries/#query-blockedReleases) | | `active` | Published, or delivered complete | [releases](https://docs.schema.fm/reference/releases/#query-releases), [release](https://docs.schema.fm/reference/releases/#query-release) | | `withdrawn` | Purged by the label, or taken down with [withdrawRelease](https://docs.schema.fm/reference/releases/#mutation-withdrawRelease) | [releases](https://docs.schema.fm/reference/releases/#query-releases), [release](https://docs.schema.fm/reference/releases/#query-release) | A release never returns to `draft`. Publishing a draft makes it `active` directly. Delivered releases never pass through `draft` at all — a DDEX message is already an assertion by the label. The one way back is out of `withdrawn`: [reinstateRelease](https://docs.schema.fm/reference/releases/#mutation-reinstateRelease) returns a release to the state it was withdrawn from. ## Drafts: authored, not yet asserted A draft is work in progress. It can have no UPC, no tracks, no title. It is visible only to credentials that can author (`catalog_write`), because a read-only integration powering someone's app has no business showing a release nobody has published. [publishRelease](https://docs.schema.fm/reference/releases/#mutation-publishRelease) is the moment of assertion. It checks the release is complete — an identifier, at least one resource, bytes behind every resource, a front cover — and if so makes it `active`. If not, it tells you everything that is missing, and the draft stays a draft. Publishing is recorded as a *source*: who asserted the release, and when. The person who publishes is the one making the claim, which is why it is the publisher, not whoever started the draft, that is recorded. ## Pending: delivered, but held A delivered release is held back as `pending` when it is not ready: - some files it references have not arrived, or - it has no front cover with bytes behind it, or - your catalog is set to hold complete deliveries for review. Nothing should reach a listener missing its audio or its artwork, so pending releases are not in `releases`. They are not hidden, though: [blockedReleases](https://docs.schema.fm/reference/deliveries/#query-blockedReleases) lists every one with the reasons, because a stuck delivery is something someone needs to chase. Promotion is automatic. When the missing files land — by redelivery, over SFTP, or with [deliverFile](https://docs.schema.fm/reference/deliveries/#mutation-deliverFile) — the release goes `active` without anyone pressing anything. See [Out-of-order delivery](https://docs.schema.fm/explanation/out-of-order-delivery.md). A pending release that will never be completed can be thrown away with [discardBlockedRelease](https://docs.schema.fm/reference/deliveries/#mutation-discardBlockedRelease). Only pending releases can be discarded: they were never visible, so nothing is taken back. ## Active: in the catalog An active release is what your applications read. `availableFrom` records when it became available in your catalog. That is your catalog's own gate, and distinct from the label's commercial windows in `deals`, which say when a given commercial model may sell it. ## Withdrawn: taken down from this catalog A label takes a release down by sending a DDEX `PurgeReleaseMessage`, over SFTP or through [deliverMessage](https://docs.schema.fm/reference/deliveries/#mutation-deliverMessage). The release it names becomes `withdrawn` **in your catalog only** — another catalog holding the same UPC is untouched. A takedown that reaches you some other way, such as an email or a rights dispute, is [withdrawRelease](https://docs.schema.fm/reference/releases/#mutation-withdrawRelease). Either way, withdrawal is a state, not a delete: - The release stays listed by [releases](https://docs.schema.fm/reference/releases/#query-releases) with `state: "withdrawn"` and a `withdrawnAt`, so your applications can see the takedown and act on it. - Its tracks, artwork and files stay. Media URLs are not refused; what you do with a withdrawn release's files is a matter for your agreement with the label. - A `pending` release can be withdrawn too, and then does not go live when its files arrive. A purge never creates a release and never changes metadata. A purge naming a release you do not hold does nothing, and one sent over SFTP cannot withdraw a release a *different* delivery account delivered — two distributors delivering into one catalog cannot take down each other's releases. Redelivering a withdrawn release updates its metadata but leaves it withdrawn: a takedown stays until someone undoes it with [reinstateRelease](https://docs.schema.fm/reference/releases/#mutation-reinstateRelease), which returns it to `active`, or to `pending` if it was still waiting for files. ## Being told when a release changes state A [webhook](https://docs.schema.fm/guides/receiving-webhooks.md) subscribed to `release.state_changed` hears about every transition into `pending`, `active` or `withdrawn` — a delivered release arrives as `from: null`, a published draft as `from: "draft"`, a takedown as `to: "withdrawn"` and a reinstatement as `from: "withdrawn"`. Drafts themselves are never announced. `release.available` is the one to act on: it fires when a release is active **and** its `availableFrom` has come. For a release that goes live with a date in the future, that is the date, not the moment it went live, so a receiver that waits for `release.available` never acts early. See [Webhook events](https://docs.schema.fm/reference/webhook-events.md). ## Editing after publication Published and delivered releases can still be corrected with [updateRelease](https://docs.schema.fm/reference/releases/#mutation-updateRelease). When you edit a release a label delivered, `locallyModifiedAt` records that it no longer matches what was sent. That matters because deliveries win: if the label redelivers, their version replaces yours. `locallyModifiedAt` is how you find the releases where that would undo a correction — and a prompt to send the fix upstream. --- # Territories and languages > Why //SCHEMA keeps every variant of a title or credit, why "no territory" is not the same as "Worldwide", and how territory filters resolve. Source: https://docs.schema.fm/explanation/territories-and-languages/ ## Every variant, verbatim A release's title can arrive once, or once per language, or once per script of the same language, or once per territory. The same goes for artist names, credits and copyright notices. //SCHEMA stores each variant exactly as it was delivered, with its qualifiers, and discards nothing. The reason is simple: //SCHEMA has to be able to reproduce what it was sent. A system that collapsed variants — kept only the English title, say — could never regenerate the original delivery, and would have made a presentation decision on every customer's behalf. ## Picking one is lossy Convenience fields such as `Release.title` and `Party.name` return a single value, preferring the one the sender marked default. Often, nothing is marked default: a Japanese release routinely carries a native-script and a romanized title, neither flagged. Which of the two you get is then arbitrary. That is why the full sets (`titles`, `names`) exist, and why the choice of which to show is left to you. ## Language tags are whole tags `language` holds the complete tag as delivered — `ja-Jpan`, `ja-Latn`, `zh-Hans-CN` — and `script` pulls out the script subtag for convenience. Filters match the whole tag, not a prefix, because `ja` would match both `ja-Jpan` and `ja-Latn`, and telling those apart is usually the point. Filter by `script` to choose a writing system. ## "No territory" is not "Worldwide" A variant can be qualified with a territory, with the explicit value `Worldwide`, or with nothing at all. //SCHEMA keeps these three apart: - **A specific territory** (`DE`) — applies there. - **Nothing** (`territory: null`) — the sender did not restrict it, so it applies anywhere nothing more specific does. - **`Worldwide`** — the sender explicitly said Worldwide. It would be convenient to treat `null` as `Worldwide`, but then //SCHEMA could no longer tell you, or regenerate, what the sender actually said. ## How territory filters resolve When you pass `territory: "DE"` to `titles`, `names`, `contributors`, `plines` or `clines`, you get what applies in Germany: 1. variants scoped to `DE`; and, for anything with no `DE`-specific variant, 2. the unqualified (`null`) variant. Variants scoped to other territories are excluded — and so are explicit `Worldwide` variants, unless you ask for `territory: "Worldwide"`. A plain equality filter would drop the unqualified variants, which in most real deliveries are most of the data. "Anything" here means the same slot: for a title, the same type, language and script; for a credit, the same party in the same role. A `DE` title in German does not displace an unqualified title in Japanese. ## Stored verbatim, matched loosely Values are stored exactly as delivered, but filters compare case-insensitively: `role: "mainartist"` finds `MainArtist`. You never need to know how a particular sender capitalised a value to find it. --- # How webhooks are delivered > Why an event is always readable by the time it arrives, why it may arrive twice or out of order, why payloads are thin, and what happens when your receiver is down. Source: https://docs.schema.fm/explanation/webhook-delivery/ ## An event arrives only after its data is readable When something changes — a delivery is processed, a release goes live — the event announcing it is recorded **in the same database transaction** as the change itself. The two commit together or not at all, and the sender only ever sees committed events. So by the time your receiver is told about something, it is already readable through the API. There is no window in which an event names a release the API cannot find yet, and no event is ever lost because a process stopped between making a change and announcing it. ## Thin payloads An event says what changed and how to look it up — identifiers, the old and new state — not the catalog data itself. Three reasons: - **Access stays with the API.** Reading a release needs a credential with `catalog_read`. A payload carrying the release would be a second way to read it that bypassed that check. - **No stale snapshots.** A delivery retried hours later would present old titles as current. Fetching on receipt always gets what is true now. - **Less to trust.** Titles, names and notes are written by whoever delivered the release. Keeping them out of payloads keeps them out of systems that act on payloads automatically. Membership events are the exception: they carry the person's address and role, because a removed member cannot be looked up afterwards. ## At least once, in no particular order A delivery counts as done when your receiver answers `2xx` within 10 seconds. If //SCHEMA's sender stops after you answered but before it recorded the answer, it sends again — so an event can arrive **twice**. Every attempt carries the same `webhook-id`; deduplicate on it. Retries also mean events can arrive **out of order**: a retried `release.state_changed` can land after a later one for the same release. Treat each event as a prompt to fetch the current state, not as the state itself. ## Retries and automatic disabling A failed delivery is retried at widening intervals — 30 seconds, 2 minutes, 10 minutes, an hour, 3 hours, then every 6 hours — for about three days, and then marked failed. Every attempt is in the endpoint's delivery log, with your receiver's status and the first 4 KiB of what it answered. An endpoint whose every delivery has failed for **five consecutive days** is turned off, and your organization's owners are emailed. "Consecutive" is meant literally: a failure more than a day after the previous one starts the count again, so an endpoint that was down a week ago and has been quiet since is not turned off by one unlucky retry. Turning an endpoint back on delivers events from that moment. What it missed is not replayed automatically — redeliver what you need from its log. ## When an endpoint receives an event An endpoint receives an event when it is enabled, subscribed to the event's type, and — for an event that belongs to a catalog — covers that catalog. It receives only events that happen **after** it was created or last turned on: creating an endpoint never replays history. `release.available` has one subtlety. A release can go live with an availability date in the future, and on that date nothing in the catalog changes. //SCHEMA checks every minute for releases whose date has come, and sends `release.available` dated by the date itself. It looks back a day, so nothing is announced late as news, and each release and date is announced once. If a sender later moves the date, the new date is a new event. ## Where webhooks can be sent Only to `https://` on the default port, and never to a private, loopback or link-local address. The address a name resolves to is checked at the moment of connecting, and redirects are never followed, so a public URL cannot be used to reach something inside //SCHEMA's network. Every owner of your organization is emailed when an endpoint is created or widened. Webhook configuration is not available to agents over [MCP](https://docs.schema.fm/guides/ai-agents.md), whatever a key's scopes. --- # Use //SCHEMA from an AI agent > Connect Claude Code or another MCP client to your catalog, choose a key for it, and point coding agents at documentation they can read. Source: https://docs.schema.fm/guides/ai-agents/ ## Connect Claude Code Mint a key for the agent (see [Choose a key](#choose-a-key)), then: ```sh claude mcp add --transport http schema https://api.schema.fm/mcp \ --header "Authorization: Bearer sk_..." ``` Any MCP client that supports the Streamable HTTP transport and a bearer header connects the same way: the endpoint is `https://api.schema.fm/mcp`, and the key goes in `Authorization`, as it does for [GraphQL requests](https://docs.schema.fm/guides/authentication.md#send-the-key). The server acts on the key's catalog and nothing else. No tool takes a catalog id, for the same reason no GraphQL field does — see [Catalogs and tenancy](https://docs.schema.fm/explanation/catalogs-and-tenancy.md). ## Choose a key Give the agent its own key rather than sharing one with an application, so you can [revoke it](https://docs.schema.fm/guides/managing-api-keys.md#revoke-a-key) on its own. | The agent should… | Scopes | |---|---| | Answer questions about the catalog and its deliveries | `catalog_read` | | …and report storage and ingest volume | `catalog_read`, `usage_read` | | …and read drafts that are still being authored | `catalog_read`, `catalog_write` | The MCP server is **read-only** in this version, whatever scopes the key holds. A write scope only makes drafts visible to it. ## What it can do | Tool | What it answers | |---|---| | `whoami` | Which organization and catalog the key acts on, and its scopes. | | `search_releases` | Published releases matching a title, artist or label. | | `get_release` | One release in full: tracks, credits, labels, titles, dates, deals. | | `lookup_vocab` | The allowed DDEX values of one kind, such as `DisplayArtistRole`. | | `delivery_triage` | Blocked releases and why, files still expected, recent messages. | | `usage` | Bytes held and ingested this period. Quantities only, never prices. | | `graphql_query` | Any read-only GraphQL query. Mutations are refused. | | `search_docs`, `read_doc` | These docs, searched and read as Markdown. | | `describe_type` | The documented definition of one GraphQL type or operation. | Tools a key cannot use are not listed to it. Each catalog tool runs a GraphQL query as the key, so it can see exactly what the same key could see over `POST /graphql`. The server has no tools for minting or revoking keys, managing members, delivery accounts, the trial or the billing portal, and will not gain them when write tools are added. An agent reads text that customers and senders wrote — release titles, failure reasons — and nothing written there should be able to reach your credentials or your account. **Webhooks are refused entirely**, even to read: `graphql_query` rejects any query that selects a `webhook…` field, under any alias or fragment. A webhook is a standing copy of your events sent somewhere else, and "add a webhook to this URL" is exactly the instruction a prompt hidden in a release title would try to give. Manage webhooks in the console or with the GraphQL API. ## Point a coding agent at the docs An agent writing code against the API, rather than connected to it, can read these docs directly: - [/llms.txt](https://docs.schema.fm/llms.txt) lists every page with a one-line summary, linking to a Markdown copy of each. - Every guide, tutorial and explanation is at its own URL with `.md` appended in place of the trailing slash: this page is `https://docs.schema.fm/guides/ai-agents.md`. - [/llms-full.txt](https://docs.schema.fm/llms-full.txt) is all of them in one file. - [/schema.graphql](https://docs.schema.fm/schema.graphql) is the schema with every field's documentation — the reference an agent needs when it writes a query. To make that automatic in Claude Code, add a line to your project's `CLAUDE.md`: ```markdown //SCHEMA API docs: https://docs.schema.fm/llms.txt — read the relevant page before writing code against it; field shapes are in /schema.graphql. ``` --- # Size cover art > Define the sizes your apps draw covers at, then fetch any cover at one of them by name. Source: https://docs.schema.fm/guides/artwork-sizes/ ## Define your sizes Owners and admins define sizes in the console under **Settings**, or with any credential holding `config_write`: ```request query: | mutation { createArtworkSize(input: { label: "tile-600" width: 600 height: 600 crop: FILL format: JPEG }) { label quality spec } } response: | { "data": { "createArtworkSize": { "label": "tile-600", "quality": 85, "spec": "img-fill-600x600-jpeg-q85-v1" } } } ``` | Crop | Result | |---|---| | `FILL` | Exactly 600×600: the cover is scaled to cover the box and center-cropped. | | `FIT` | At most 600×600: scaled to fit, aspect kept, nothing cropped. | A size belongs to your organization and applies to every catalog it owns, so production and sandbox draw covers alike. Members, and keys with `catalog_read`, can list the sizes with [artworkSizes](https://docs.schema.fm/reference/artwork-sizes/#query-artworkSizes) but cannot change them. An organization may have up to 20 sizes, on every plan. Every front cover you hold is rendered at a new size within moments of saving it, and every new cover within moments of arriving. Sizes are made of each release's `FrontCoverImage` only — back covers, booklet pages and other images are not rendered. Rendered covers are stored files and count toward your storage while one of your sizes asks for them: if another organization holds the same cover at the same size, you are not charged for theirs. ## Fetch a cover by name Ask the release for its `coverArt` at the label: ```request query: | query { release(upc: "0196871402293") { coverArt(size: "tile-600") { status width height url(ttlSeconds: 600) } } } response: | { "data": { "release": { "coverArt": { "status": "READY", "width": 600, "height": 600, "url": "https://media.schema.fm/sha256/7c/1e/7c1e…?X-Amz-Expires=600&X-Amz-Signature=…" } } } } ``` `coverArt` is `null` when the release has no front cover yet. If you already have the resource, `artwork(size:)` on it answers the same; asked of any image but the `FrontCoverImage`, it is an `ARTWORK_NOT_FRONT_COVER` error. It needs `media_read`, as the original cover does. The URL is minted and logged only when you select `url`, so asking for `width` and `height` to lay out a page mints nothing. Use `contentVersion` on the resource as your cache key: it changes when the cover is replaced. ## When a cover is not ready Until a rendition is made, `status` is `PENDING`, `url` is `null`, and `width` and `height` already say what it will be — lay the page out and fetch again shortly. `FAILED` means the cover could not be decoded; //SCHEMA has the reason. A label your organization does not have is an `UNKNOWN_ARTWORK_SIZE` error whose message lists the labels you do. ## Changing sizes Renaming a size renders nothing: what is stored is keyed by `spec`, not by the label. Changing the dimensions, crop, format or quality makes a new rendition, `PENDING` until made, and returns the size under a new `artworkSizePk` — the old definition is kept, retired, so past storage still prices from it. The label carries over, so code that asks by label is unaffected. A cover smaller than a size is never scaled up unless the size sets `upscale: true`, and `width` and `height` always say what you will get. --- # Authenticate requests > Send an API key with every request, keep it on the server, and choose the smallest set of scopes that works. Source: https://docs.schema.fm/guides/authentication/ ## Send the key Put the key in the `Authorization` header of every request: ```request query: | query { viewer { actorType catalogPk scopes } } ``` Load the key from the environment or a secret manager — never from source code: ```js const SCHEMA_API_KEY = process.env.SCHEMA_API_KEY; if (!SCHEMA_API_KEY) throw new Error("SCHEMA_API_KEY is not set"); ``` ## Keep keys on the server A key grants everything its scopes allow on its catalog, to anyone who holds it. Call //SCHEMA from your backend and expose only what your app needs. Never ship a key in a web page, a mobile app or a desktop binary. ## Choose the narrowest scopes Give each integration its own key with only the scopes it uses: | Integration | Scopes | |---|---| | A catalog browser or search page | `catalog_read` | | A player | `catalog_read`, `media_read` | | A metadata sync that edits releases | `catalog_read`, `catalog_write` | | An uploader for masters and artwork | `catalog_read`, `catalog_write`, `media_write` | | An automated DDEX deliverer | `catalog_read`, `catalog_write`, `media_write` | | Provisioning tooling (keys, members, SFTP accounts) | `config_read`, `config_write` | See [the full table](https://docs.schema.fm/reference/authentication.md#what-each-operation-requires) of which operation needs which scope. ## Discover what a key can do Ask [viewer](https://docs.schema.fm/reference/viewer/#query-viewer) at startup and fail fast if a scope you rely on is missing. `viewer.scopes` spells scopes `CatalogRead`, so normalize before comparing with `catalog_read` (`schema()` is the helper defined [below](#handle-rejection)): ```js const wire = (s) => s.replace(/[A-Z]/g, (c, i) => (i ? "_" : "") + c.toLowerCase()); const { data } = await schema(`{ viewer { scopes } }`); const held = new Set(data.viewer.scopes.map(wire)); for (const need of ["catalog_read", "media_read"]) { if (!held.has(need)) throw new Error(`API key is missing ${need}`); } ``` ## Handle rejection An invalid, revoked or expired key does not produce an HTTP error; the response carries an error with `extensions.code` of `UNAUTHENTICATED`. Check for it and stop retrying — the key will not start working on its own. ```js async function schema(query, variables) { const res = await fetch("https://api.schema.fm/graphql", { method: "POST", headers: { Authorization: `Bearer ${SCHEMA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ query, variables }), }); const body = await res.json(); if (body.errors?.some((e) => e.extensions?.code === "UNAUTHENTICATED")) { throw new Error("//SCHEMA rejected the API key"); } return body; } ``` ## Rotate a key Keys do not need to expire, but you can give one an `expiresAt` when you mint it. To rotate without downtime, see [Manage API keys](https://docs.schema.fm/guides/managing-api-keys.md#rotate-a-key). --- # Deliver DDEX over SFTP > Upload an ERN message and its media to your organization's own SFTP endpoint — one directory per delivery, picked up as soon as each file is closed. Source: https://docs.schema.fm/guides/delivering-ddex-over-sftp/ This is the path a distributor or label already knows how to use: put the ERN XML and its audio and artwork in a directory, upload the directory, done. Nothing calls an API, and nothing has to be sequenced. To deliver the same files from code instead, see [Deliver DDEX through the API](https://docs.schema.fm/guides/delivering-ddex.md). ## Your organization's endpoint **Every organization gets its own SFTP endpoint** — its own reserved IP address and its own SSH host key. The address does not change for as long as you have the endpoint, so partners can allow it through their firewalls. It is not a shared host that partners are partitioned inside, which matters for two reasons: usernames are yours to choose (two organizations may both have a `lanternfish` without colliding), and the host key a partner pins belongs to you alone. [catalog](https://docs.schema.fm/reference/viewer/#query-catalog) gives you the address and port to hand out: ```request query: | query { catalog { sftpHost sftpPort } } response: | { "data": { "catalog": { "sftpHost": "203.0.113.24", "sftpPort": 22 } } } ``` A `null` `sftpHost` means the endpoint has not been provisioned yet. It is created the first time you issue a delivery account, so issue one and check again in a few minutes. ## Get a login Deliveries are authenticated as a **delivery account** — one per partner, not one per organization — using either an SSH key or a password. Issuing them is covered in [Manage SFTP delivery accounts](https://docs.schema.fm/guides/sftp-delivery-accounts.md); you need `config_write`. Which account uploaded a file decides which catalog the delivery lands in, so an organization with a production and a sandbox catalog gives out separate accounts rather than separate directories. ## One directory per delivery Every account is chrooted to its own home, with a single writable directory called `upload/`. Inside it, **each delivery is one directory**, holding the ERN XML and every file it references: ``` upload/ └── 20260917_0196871402293/ ← the delivery, named by you ├── 0196871402293.xml ├── resources/ │ ├── A1_Signal_Fire.flac │ └── A2_Nightcall_Ferry.flac └── images/ └── front.jpg ``` Two rules about that layout, and both are load-bearing: **The directory name is the delivery namespace.** It is what ties a message to its files, so one delivery per directory, and a name you do not reuse — a date and the UPC, a batch id from your own system, anything unique to you. Reusing a name can let a leftover file from the earlier batch satisfy an identically named path in the later one. **Paths inside the directory must match the paths in the XML.** A `` element giving `resources/A1_Signal_Fire.flac` is matched against exactly that path below the delivery directory. Subdirectories are fine and are part of the path; the delivery directory itself is not. `done` is reserved — see below — so a delivery cannot be called that. ## Upload it ```sh sftp -P 22 lanternfish@203.0.113.24 sftp> put -r 20260917_0196871402293 upload/ ``` Or non-interactively, which is what a nightly job usually wants: ```sh rsync -av -e 'ssh -p 22' \ 20260917_0196871402293 \ lanternfish@203.0.113.24:upload/ ``` **Each file is picked up when your client closes it**, not when it appears, so a partly written file is never read and there is no need to upload to a temporary name and rename. There is also no ordering requirement: the XML may go up before its media or after it, in the same session or a day later, and the delivery converges on the same result either way. See [Out-of-order delivery](https://docs.schema.fm/explanation/out-of-order-delivery.md) for what happens in between. These file types are ingested: | | | |---|---| | Message | `.xml` | | Audio | `.wav` `.flac` `.mp3` `.aiff` `.aif` `.m4a` `.ogg` `.aac` | | Image | `.jpg` `.jpeg` `.png` `.gif` `.webp` `.tiff` `.tif` | | Video | `.mp4` `.mov` `.avi` `.mkv` `.m4v` | Anything else — a checksum manifest, a `.DS_Store`, a PDF one-sheet — is set aside rather than ingested, and does not hold the delivery up. ## What the directory tells you Watch the delivery directory to see how far it has got. Each file is moved to `upload/done//` once it has been handled, and when the last one goes the now-empty delivery directory is removed. So: - **The delivery directory is gone.** Every file was handled. That is the filesystem's version of "complete". - **Files are still sitting in it.** Either they have not been processed yet, or processing failed and they are being retried. A file that keeps failing stays where it is rather than being silently discarded. - **Files appear under `done/`.** They were handled. Unknown file types land here too — being set aside is not the same as being ingested. Re-uploading a delivery is always safe. //SCHEMA deduplicates on the message and on file content, so a repeated delivery is recorded as a duplicate and changes nothing. ## Check it landed [messages](https://docs.schema.fm/reference/deliveries/#query-messages) lists recent deliveries, from SFTP and the API alike, with the status of each: ```request query: | query { messages(limit: 5) { fileName ddexMessageId status failureReason dateReceived } } response: | { "data": { "messages": [ { "fileName": "0196871402293.xml", "ddexMessageId": "LANTERNFISH_20260917_0196871402293", "status": "partial", "failureReason": null, "dateReceived": "2026-09-17T02:14:09+00:00" } ] } } ``` `partial` means the message is in and //SCHEMA is still waiting for files — the usual state a few seconds into an upload. It becomes `ingested` when the last one lands; `failed` means the XML could not be read, and `failureReason` says why. A delivery stays out of the catalog until every file it references has arrived and it has cover art. [blockedReleases](https://docs.schema.fm/reference/deliveries/#query-blockedReleases) says what is still held back and why, and [outstandingFiles](https://docs.schema.fm/reference/deliveries/#query-outstandingFiles) names the exact paths still being waited on — which is the fastest way to find a path that does not match the XML: ```request query: | query { outstandingFiles { deliveryNamespace relativeUri title } } ``` ## When nothing happens at all | Symptom | Cause | |---|---| | Connection refused, or the host does not resolve | The endpoint is not provisioned. Check `sftpHost` is not `null`. | | Permission denied writing to the login directory | Everything is uploaded inside `upload/`; the directory above it is not writable, as SSH requires. | | Files stay put and never reach `done/` | The account was revoked. Files are left exactly where they landed rather than being guessed into a catalog. | | The message ingests, the release never appears | A file is missing or its path does not match the XML. Check `outstandingFiles`. | | The files vanish but no release appears | Check `messages` for a parse or mapping error on that delivery. | ## See also - [Manage SFTP delivery accounts](https://docs.schema.fm/guides/sftp-delivery-accounts.md) — issuing logins, keys and rotation. - [Deliver DDEX through the API](https://docs.schema.fm/guides/delivering-ddex.md) — the same pipeline, over HTTPS. - [Out-of-order delivery](https://docs.schema.fm/explanation/out-of-order-delivery.md) — how messages and files find each other. --- # Deliver DDEX through the API > Deliver an ERN message and its media files over HTTPS instead of SFTP — in any order — and track the delivery until it goes live. Source: https://docs.schema.fm/guides/delivering-ddex/ Use this when you already have DDEX ERN files — from a distributor, or exported from another system — and want to deliver them from code rather than over SFTP. To create a release from scratch instead, see [Publish a release](https://docs.schema.fm/tutorials/publish-a-release.md). You need `catalog_write` and `media_write`. ## The flow 1. Upload the XML with [requestDeliveryUpload](https://docs.schema.fm/reference/uploads/#mutation-requestDeliveryUpload) and a `PUT`, exactly as in [Upload media](https://docs.schema.fm/guides/uploading-media.md). 2. [deliverMessage](https://docs.schema.fm/reference/deliveries/#mutation-deliverMessage) parses and ingests it, and returns a `deliveryNamespace` and how many files it is still waiting for. 3. For each media file: upload it the same way, then [deliverFile](https://docs.schema.fm/reference/deliveries/#mutation-deliverFile) with the namespace and the path the message used for it. 4. When the last file lands, the release goes live on its own. Steps 2 and 3 can happen in either order. Files delivered before their message are kept, and matched when the message arrives. ## 1. Upload the message ```request query: | mutation { requestDeliveryUpload( sha256: "4c1d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d" byteSize: 18244 ) { alreadyPresent uploadUrl requiredHeaders { name value } } } ``` `PUT` the XML to `uploadUrl` with the required headers, unless `alreadyPresent` is `true`. ## 2. Deliver the message ```request query: | mutation { deliverMessage( sha256: "4c1d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d" fileName: "0196871402293.xml" ) { messagePk deliveryNamespace awaitingFiles duplicate } } response: | { "data": { "deliverMessage": { "messagePk": 220344, "deliveryNamespace": "console-4c1d8e7f6a5b", "awaitingFiles": 3, "duplicate": false } } } ``` - `deliveryNamespace` ties this message to its files. It is derived from the message's bytes, so delivering the same file again lands in the same place. - `awaitingFiles: 0` means the delivery is already complete — every file it references was already in //SCHEMA. - `duplicate: true` means this exact message had already been ingested. Nothing changed, and that is not an error. A message //SCHEMA cannot read returns an error beginning `ERN parse error` or `ERN map error`, with the details. ## 3. Deliver each file Upload each media file (step 1 again), then land it against the delivery. `relativeUri` is the path exactly as the message's `` element gives it: ```request query: | mutation { deliverFile( deliveryNamespace: "console-4c1d8e7f6a5b" relativeUri: "resources/A1_Signal_Fire.flac" sha256: "9f2c4e0d4b1a7c3e8f6d5b2a1c0e9d8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d" ) { outcome resourcePk } } response: | { "data": { "deliverFile": { "outcome": "promoted", "resourcePk": 771201 } } } ``` | `outcome` | Meaning | Action | |---|---|---| | `promoted` | Matched; the resource has its bytes. | None. | | `orphan` | Stored, but nothing is waiting for it. | Fine if the message is still to come. Otherwise check the namespace and path. | | `duplicate` | Already delivered at this path. | None. | | `hashMismatch` | The bytes contradict the checksum in the message. | Deliver the correct file; the resource recovers on its own. | ## Don't know the namespace? If the message was delivered some other way — over SFTP, or by someone else — find what it is waiting for with [outstandingFiles](https://docs.schema.fm/reference/deliveries/#query-outstandingFiles) and match your file's name against the end of `relativeUri`: ```request query: | query { outstandingFiles { deliveryNamespace relativeUri title } } ``` ## 4. Watch it go live A delivery stays out of the catalog until every file has arrived and it has cover art. [blockedReleases](https://docs.schema.fm/reference/deliveries/#query-blockedReleases) lists what is still held back and why: ```request query: | query { blockedReleases { upc title problems } } response: | { "data": { "blockedReleases": [ { "upc": "0196871402293", "title": "Paper Radio", "problems": ["1 of 3 files have not arrived yet"] } ] } } ``` When the list no longer includes it, the release is live — find it with `release(upc:)`. Whether a complete delivery goes live immediately or waits for review depends on your catalog's activation setting. If a delivery will never be completed, remove it with [discardBlockedRelease](https://docs.schema.fm/reference/deliveries/#mutation-discardBlockedRelease). ## Check the delivery log [messages](https://docs.schema.fm/reference/deliveries/#query-messages) lists recent deliveries — through the API and over SFTP — with their status, and [ingestionStats](https://docs.schema.fm/reference/deliveries/#query-ingestionStats) summarises them. ## See also - [Out-of-order delivery](https://docs.schema.fm/explanation/out-of-order-delivery.md) — how files and messages find each other. - [Manage SFTP delivery accounts](https://docs.schema.fm/guides/sftp-delivery-accounts.md) — to let partners deliver directly. --- # Work with localized metadata > Pick the right title or artist name for a language, script or territory, and read territory-specific credits and copyright notices. Source: https://docs.schema.fm/guides/localized-metadata/ ## The convenience fields, and when they are not enough [Release.title](https://docs.schema.fm/reference/releases/#Release), [Resource.title](https://docs.schema.fm/reference/resources/#Resource) and [Party.name](https://docs.schema.fm/reference/parties/#Party) each give you one value, preferring the variant the sender marked as default. That is fine for a first pass. It is not fine when the catalog carries the same title in more than one script — a Japanese release routinely has both `夢のララバイ` and `Yume no Lullaby`, and often neither is marked default. For those, ask for the full set and choose. ## List every variant ```request query: | query { release(upc: "4988006702236") { titles { value type language script territory isDefault } } } response: | { "data": { "release": { "titles": [ { "value": "夢のララバイ", "type": "DisplayTitle", "language": "ja-Jpan", "script": "Jpan", "territory": null, "isDefault": false }, { "value": "Yume no Lullaby", "type": "DisplayTitle", "language": "ja-Latn", "script": "Latn", "territory": null, "isDefault": false } ] } } } ``` ## Select by script or language To show the native-script title: ```graphql query { release(upc: "4988006702236") { titles(type: "DisplayTitle", script: "Jpan") { value } } } ``` `language` matches the **whole** tag. `language: "ja"` does not match `ja-Jpan`; that is deliberate, because a prefix match could not tell `ja-Jpan` from `ja-Latn` — the very distinction you are trying to make. Use `script` to choose a writing system, and `language` when you know the exact tag. The same filters are on [Resource.titles](https://docs.schema.fm/reference/resources/#Resource) and [Party.names](https://docs.schema.fm/reference/parties/#Party): ```graphql query { release(upc: "4988006702236") { contributors(role: "MainArtist") { party { name romanized: names(script: "Latn") { value } native: names(script: "Jpan") { value } } } } } ``` ## Select by territory Pass a territory to get what applies there. The rule is: 1. a variant scoped to that territory, if there is one; otherwise 2. the unqualified variant (the sender did not restrict it, so it applies everywhere). A variant scoped to a *different* territory is never returned — and neither is one explicitly scoped to `Worldwide` unless you ask for `Worldwide`. ```graphql query { release(upc: "0196871402293") { titles(territory: "DE") { value territory } } } ``` If the sender delivered a German title scoped to `DE` and an unqualified title, you get the German one. For `territory: "FR"` you get the unqualified one. ## Credits by territory Credits follow the same rule. This returns the credits that apply in the US — US-specific ones, plus every credit the sender did not restrict: ```graphql query { release(upc: "0196871402293") { contributors(territory: "US") { party { name } role roleKind } } } ``` ## Tell billing from personnel `MainArtist` and `FeaturedArtist` are billing roles (`roleKind: "DisplayArtistRole"`); `Producer`, `Mixer` and `AssociatedPerformer` describe people who worked on the recording (`roleKind: "ResourceContributorRole"`). Filter on `roleKind` rather than guessing from the role's name — the contributor vocabulary also contains `FoleyArtist` and `MakeUpArtist`, which are not performers. ## Copyright notices ℗ notices (the recording) and © notices (the composition or artwork) are separate fields with the same filters: ```graphql query { release(upc: "0196871402293") { plines(territory: "US") { value year } clines { value year } resources { title plines { value year } } } } ``` A track's ℗ can differ from the release's — typically on a compilation, where each licensed track keeps its own owner's notice. `year` is a string, exactly as delivered. Senders do not always send a bare year, so parse it defensively. ## Why it works this way See [Territories and languages](https://docs.schema.fm/explanation/territories-and-languages.md). --- # Manage API keys > Mint keys with the right scopes, list them, rotate them without downtime, and revoke one that has leaked. Source: https://docs.schema.fm/guides/managing-api-keys/ ## Mint a key ```request query: | mutation { mintApiKey( name: "Player backend" scopes: ["catalog_read", "media_read"] expiresAt: "2027-09-18T00:00:00Z" ) { apiKeyPk token tokenPrefix } } response: | { "data": { "mintApiKey": { "apiKeyPk": 305, "token": "sk_8Hq2xJ0vZQm1r3TbW7cN5yLs4KpA9eUdGfHj6VxOiBt", "tokenPrefix": "sk_8Hq2xJ0vZ" } } } ``` Store `token` in your secret manager straight away. It is never shown again. - **Name every key** for what uses it. The name is required, because a list of anonymous keys cannot be audited. - **Scopes** use the wire spelling. You can only grant scopes you hold; [grantableScopes](https://docs.schema.fm/reference/api-keys/#query-grantableScopes) lists them. - **`expiresAt`** is optional. A key without one works until revoked. - **`catalogPk`** is optional and defaults to your own catalog. Pass another of your organization's catalogs — a sandbox, say — to mint a key for it. ## List keys ```request query: | query { apiKeys { apiKeyPk name tokenPrefix scopes createdAt expiresAt lastUsedAt revokedAt } } ``` `tokenPrefix` is the first twelve characters of the token — enough to match a key in your secret manager to its row here. `lastUsedAt` is updated lazily: use it to find dormant keys, not to audit individual requests. ## Rotate a key Keys can overlap, so rotation needs no downtime: 1. Mint a new key with the same scopes. 2. Deploy it everywhere the old one is used. 3. Watch the old key's `lastUsedAt` stop advancing. 4. Revoke the old key. ## Revoke a key ```request query: | mutation { revokeApiKey(apiKeyPk: 305) } ``` Revocation is immediate: the next request with that key is rejected. The key remains in `apiKeys` with `revokedAt` set, so you can still see when it was created and when it stopped working. ## If a key leaks Revoke it first, then mint a replacement. A key could only ever mint keys with its own scopes, so check `apiKeys` for any created while it was exposed and revoke those too. --- # Manage members > Add people to your organization, change their roles, reset passwords, and remove access. Source: https://docs.schema.fm/guides/managing-members/ ## Add a member ```request query: | mutation { addOrgMember(input: { email: "kai@halcyon.example", displayName: "Kai", role: "member" }) { userPk role created password } } response: | { "data": { "addOrgMember": { "userPk": 1052, "role": "member", "created": true, "password": "7KQ2M-D9XHT-4VRWB-P3NZE" } } } ``` - If `created` is `true`, a new account was made. Send the person their `password` securely; it is shown only now. - If `created` is `false`, the person already had a //SCHEMA account — they work with another organization too — and now also belongs to yours. They sign in with the password they already have, and `password` is `null`. `role` defaults to `member`. Ask [grantableRoles](https://docs.schema.fm/reference/members/#query-grantableRoles) which roles you may give. ## Roles | Role | Can | |---|---| | `member` | Read and author the catalog, upload media. Cannot manage credentials or people. | | `admin` | Everything, including keys, members and delivery accounts. | | `owner` | Everything an admin can, and can make others owners. | ## Change a role ```request query: | mutation { setOrgMemberRole(userPk: 1052, role: "admin") } ``` You cannot change your own role, only an owner can grant `owner`, and the last owner cannot be demoted. Role changes take effect on the member's next request. ## Reset a password ```request query: | mutation { resetOrgMemberPassword(userPk: 1052) } ``` Returns a new generated password and signs the member out everywhere. It is refused for members who also belong to another organization — their password is not yours to change — and for your own account. ## Change your own password Signed-in users change their own password with [changePassword](https://docs.schema.fm/reference/members/#mutation-changePassword), which needs the current one. Their other sessions are signed out. ## Remove a member ```request query: | mutation { removeOrgMember(userPk: 1052) } ``` Access ends immediately. The person's //SCHEMA account is not deleted — they may belong to other organizations. You cannot remove yourself. ## Webhooks for membership, and the emails owners get A [webhook](https://docs.schema.fm/guides/receiving-webhooks.md) can tell your own systems when someone is added (`member.added`), removed (`member.removed`) or given a different role (`member.role_changed`) — to provision or deprovision them elsewhere. Each carries the person's address and role. Owners also receive email about webhooks themselves: whenever an endpoint is created, or changed so that it receives more than before, every owner is told where it sends, what it receives and who made the change. If you don't recognize one, delete the endpoint and revoke the credential that created it. ## Audit access [orgMembers](https://docs.schema.fm/reference/members/#query-orgMembers) lists everyone with their role, status and last sign-in. `isYou` marks the caller's own row; `sharedWithOtherOrgs` marks people whose password you cannot reset. --- # Paginate and search > Walk an entire catalog page by page, search by title, artist or label, and load tracks for many releases in one request. Source: https://docs.schema.fm/guides/pagination-and-search/ ## Page through releases [releases](https://docs.schema.fm/reference/releases/#query-releases) takes `limit` (at most 200) and `offset`. Releases come most recently updated first. Ask [releasesCount](https://docs.schema.fm/reference/releases/#query-releasesCount) for the total, so you know when to stop: ```request query: | query Page($offset: Int!) { releasesCount releases(limit: 200, offset: $offset) { releasePk upc title updatedAt } } variables: offset: 0 response: | { "data": { "releasesCount": 1284, "releases": [ { "releasePk": 48213, "upc": "0196871402293", "title": "Paper Radio", "updatedAt": "2026-09-17T09:14:04.771520+00:00" } ] } } ``` A loop that fetches every release: ```js const PAGE = 200; const all = []; for (let offset = 0; ; offset += PAGE) { const { data } = await schema( `query ($offset: Int!) { releases(limit: 200, offset: $offset) { releasePk upc title } }`, { offset }, ); all.push(...data.releases); if (data.releases.length < PAGE) break; } ``` > [!NOTE] > Offsets are positions in a list ordered by `updatedAt`. If releases change > while you page, an item can shift between pages. For a full sync, collect by > `releasePk` and de-duplicate; re-run periodically to pick up changes. ## Search Pass `q` to search. It is a case- and accent-insensitive partial match against the release **title**, any release-level **display artist**, and any **label** — a release matches if any one of them does. Accents fold on both sides, so `sinead` finds `Sinéad`: ```request query: | query { releasesCount(q: "ghosts") releases(q: "ghosts", limit: 10) { title contributors(role: "MainArtist") { party { name } } labels { name } } } ``` Pass the same `q` to `releasesCount` to page through search results. ## Look up one release When you already know the UPC, [release](https://docs.schema.fm/reference/releases/#query-release) is a direct lookup. It returns `null` if no release in your catalog has that UPC. ## Browse by label [labels](https://docs.schema.fm/reference/parties/#query-labels) lists every label in the catalog with its release count, busiest first. A label's `partyPk` is the same on every release it appears on, so you can group releases by label on your side. ## Load nested data for many releases at once Nested fields are batched on the server. This request asks for the tracks of up to 50 releases and costs about as much as asking for one: ```request query: | query { releases(limit: 50) { upc title resources { isrc title durationSeconds } } } ``` The limit that applies is complexity — 500 selected fields per request — which counts fields in your query, not items in the response. See [Limits](https://docs.schema.fm/reference/index.md#limits). ## Other lists Every other list takes a `limit`: | Query | Default | Maximum | |---|---|---| | [messages](https://docs.schema.fm/reference/deliveries/#query-messages) | 25 | 200 | | [blockedReleases](https://docs.schema.fm/reference/deliveries/#query-blockedReleases) | 25 | 200 | | [draftReleases](https://docs.schema.fm/reference/releases/#query-draftReleases) | 25 | 200 | | [outstandingFiles](https://docs.schema.fm/reference/deliveries/#query-outstandingFiles) | 200 | 1000 | | [labels](https://docs.schema.fm/reference/parties/#query-labels) | 200 | 1000 | | [vocabValues](https://docs.schema.fm/reference/vocabularies/#query-vocabValues) | 50 | 1000 | Values above the maximum are clamped rather than rejected. --- # Receive webhooks > Create an endpoint, verify every delivery's signature, handle retries and duplicates, and rotate the secret without downtime. Source: https://docs.schema.fm/guides/receiving-webhooks/ > [!NOTE] > **Every owner of your organization is emailed** when an endpoint is > created, or changed so that it receives more than before — a new URL, more > event types or catalogs, or turned back on. A webhook sends a standing copy > of your events somewhere else, and the people who could stop one should > know it exists. If none of your owners has an address that can be emailed, > the change is refused. ## Create an endpoint Choose the [event types](https://docs.schema.fm/reference/webhook-events.md) you want, and a URL that accepts `POST` over `https://` on the default port. ```request query: | mutation { createWebhookEndpoint(input: { url: "https://hooks.paperradio.example/schema" description: "Storefront sync" eventTypes: ["release.available", "release.state_changed"] }) { secret endpoint { endpointPk enabled } } } response: | { "data": { "createWebhookEndpoint": { "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSwq3c0Jm7ZP4c=", "endpoint": { "endpointPk": 4, "enabled": true } } } } ``` Store `secret` in your secret manager straight away. It is never shown again. - **`catalogPks`** narrows an endpoint to some of your organization's catalogs, so one receiver can watch production while another watches a sandbox. Leave it out to receive every catalog, including ones you create later. - The URL must not be a private, loopback or link-local address, and may not carry a username or password — the signature is how your receiver knows a request is ours. - An organization can have up to 10 endpoints. ## Verify every delivery Each delivery is a `POST` with a JSON body and three headers, from the [Standard Webhooks](https://www.standardwebhooks.com/) specification: ```http POST /schema HTTP/1.1 Host: hooks.paperradio.example Content-Type: application/json webhook-id: evt_4ee8793102184b15940831a1e5f3ce54 webhook-timestamp: 1792280788 webhook-signature: v1,wcTZPWuxw1nGOoE/qHAFlbqmGk4VP4PsRI7NCX13zaM= ``` `webhook-signature` is an HMAC-SHA256, keyed by your secret, of `{webhook-id}.{webhook-timestamp}.{body}`. **Verify it before acting on anything**, and verify it over the body exactly as it arrived — re-serializing parsed JSON changes the bytes and breaks the signature. Reject a timestamp more than a few minutes old even when the signature is valid: that is what stops a captured request being replayed later. Most languages have a maintained Standard Webhooks library that does all of this in one call. Without one: ```js import { createHmac, timingSafeEqual } from "node:crypto"; // `rawBody` is the request body exactly as it arrived. Throws if invalid. export function verifyWebhook(secret, headers, rawBody, now = Date.now() / 1000) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"] ?? ""; if (!id || !timestamp) throw new Error("missing webhook headers"); if (Math.abs(now - Number(timestamp)) > 5 * 60) throw new Error("stale webhook"); const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key) .update(`${id}.${timestamp}.${rawBody}`) .digest(); // During a secret rotation there are two signatures; either may match. for (const sig of signatures.split(" ")) { const [version, value] = sig.split(","); if (version !== "v1" || !value) continue; const given = Buffer.from(value, "base64"); if (given.length === expected.length && timingSafeEqual(given, expected)) return; } throw new Error("invalid webhook signature"); } ``` ```python import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> None: """`raw_body` is the body exactly as received. Raises ValueError if invalid.""" msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature", "") if not msg_id or not timestamp: raise ValueError("missing webhook headers") if abs(time.time() - int(timestamp)) > 5 * 60: raise ValueError("stale webhook") key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() # During a secret rotation there are two signatures; either may match. for sig in signatures.split(" "): version, _, value = sig.partition(",") if version == "v1" and hmac.compare_digest(value, expected): return raise ValueError("invalid webhook signature") ``` ```rust // hmac = "0.12", sha2 = "0.10", base64 = "0.22" use base64::{Engine, engine::general_purpose::STANDARD}; use hmac::{Hmac, Mac}; use sha2::Sha256; /// `raw_body` is the body exactly as received; `now` is seconds since the epoch. pub fn verify_webhook( secret: &str, id: &str, timestamp: &str, signatures: &str, raw_body: &[u8], now: i64, ) -> Result<(), &'static str> { let ts: i64 = timestamp.parse().map_err(|_| "bad timestamp")?; if (now - ts).abs() > 5 * 60 { return Err("stale webhook"); } let key = STANDARD .decode(secret.trim_start_matches("whsec_")) .map_err(|_| "bad secret")?; // During a secret rotation there are two signatures; either may match. for sig in signatures.split(' ') { let Some(("v1", value)) = sig.split_once(',') else { continue }; let Ok(given) = STANDARD.decode(value) else { continue }; let mut mac = Hmac::::new_from_slice(&key).expect("any key length"); mac.update(format!("{id}.{timestamp}.").as_bytes()); mac.update(raw_body); if mac.verify_slice(&given).is_ok() { return Ok(()); } } Err("invalid webhook signature") } ``` Each of these accepts the Standard Webhooks specification's own published example and rejects a body or timestamp altered from it. ## Answer quickly, then do the work Return any `2xx` within 10 seconds and the delivery counts as done. Anything else — another status, a redirect, a timeout — is retried. So acknowledge first and process afterwards: put the event on your own queue and answer `204`. Redirects are never followed. If your URL moves, update the endpoint. ## Expect duplicates, and no particular order Deliveries are **at least once**: a retry after a timeout, or a redelivery you ask for, sends an event your receiver may already have handled. Every attempt carries the same `webhook-id`, so record the ids you have processed and skip one you have seen. Deliveries are also **unordered**: a retried event can arrive after a later one. Treat an event as a prompt to look, not as the state itself — fetch the release, message or member through the API and act on what it says now. Everything an event describes is readable through the API before the event is sent. ```request query: | query ($upc: String!) { release(upc: $upc) { releasePk state availableFrom title } } variables: upc: "0602445790135" response: | { "data": { "release": { "releasePk": 9182, "state": "active", "availableFrom": "2026-10-24T00:00:00Z", "title": "Paper Radio" } } } ``` ## Send a test [sendTestWebhook](https://docs.schema.fm/reference/webhooks/#mutation-sendTestWebhook) sends a `webhook.test` event to one endpoint through the same path as every other event. If it arrives and your verification accepts it, the integration works. ```request query: | mutation { sendTestWebhook(endpointPk: 4) } response: | { "data": { "sendTestWebhook": "evt_4ee8793102184b15940831a1e5f3ce54" } } ``` ## Read the delivery log, and redeliver Each endpoint keeps 30 days of deliveries: what was sent, how many attempts it took, and what your receiver answered. ```request query: | query { webhookEndpoint(endpointPk: 4) { failingSince deliveries(first: 10, state: "pending") { deliveryPk eventType attempts lastStatus lastError nextAttemptAt } } } response: | { "data": { "webhookEndpoint": { "failingSince": "2026-10-17T09:14:02Z", "deliveries": [ { "deliveryPk": 1182, "eventType": "release.available", "attempts": 2, "lastStatus": 503, "lastError": "http 503", "nextAttemptAt": "2026-10-17T09:26:40Z" } ] } } } ``` A failed delivery is retried for about three days, at widening intervals. An endpoint whose every delivery has failed for **five days** is turned off and your owners are emailed. Once your receiver is fixed, turn the endpoint back on with [updateWebhookEndpoint](https://docs.schema.fm/reference/webhooks/#mutation-updateWebhookEndpoint) and resend what you need with [redeliverWebhook](https://docs.schema.fm/reference/webhooks/#mutation-redeliverWebhook) — turning it on does not replay what it missed. ## Rotate the secret without downtime 1. Call [rotateWebhookSecret](https://docs.schema.fm/reference/webhooks/#mutation-rotateWebhookSecret) and store the new secret. 2. For the next 24 hours every delivery carries two signatures, one from each secret. Deploy the new secret to your receiver at any point in that window; the verification above accepts either signature. 3. After 24 hours only the new secret signs. If a secret has leaked, rotate it — and since the leak could also have let someone change the endpoint, check it: your owners would have been emailed about any change that widened it. ## Turn an endpoint off, or delete it `updateWebhookEndpoint(input: { endpointPk: 4, enabled: false })` stops deliveries and fails anything still pending, keeping the endpoint and its log. [deleteWebhookEndpoint](https://docs.schema.fm/reference/webhooks/#mutation-deleteWebhookEndpoint) removes both. --- # Manage SFTP delivery accounts > Issue SFTP logins to the labels and distributors who deliver to you, authorize their SSH keys, rotate keys, and revoke access. Source: https://docs.schema.fm/guides/sftp-delivery-accounts/ ## Your endpoint [catalog](https://docs.schema.fm/reference/viewer/#query-catalog) tells you the address and port partners connect to: ```request query: | query { catalog { sftpHost sftpPort } } response: | { "data": { "catalog": { "sftpHost": "203.0.113.24", "sftpPort": 22 } } } ``` If `sftpHost` is `null`, your organization does not have an SFTP endpoint yet. ## Issue an account with an SSH key Keys are the better choice for automated delivery. Issue the account — it starts with no password and cannot sign in yet — then authorize the key your partner sends you: ```request query: | mutation { issueDeliveryAccount(input: { username: "lanternfish", displayName: "Lanternfish Records" }) { deliveryAccountPk username password } } response: | { "data": { "issueDeliveryAccount": { "deliveryAccountPk": 64, "username": "lanternfish", "password": null } } } ``` ```request query: | mutation AddKey($key: String!) { addDeliveryKey(deliveryAccountPk: 64, publicKey: $key) { fingerprint } } variables: key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHk3b2l0Z0N6c1l4b3dQZ2pQb0VlQ3ZkR2Z0T2xY ops@lanternfish ``` Read the `fingerprint` back to your partner; they can compare it with `ssh-keygen -lf` on their side. Usernames are 3–32 characters, start with a lowercase letter, and use only `a–z`, `0–9`, `_` and `-`. ## Issue an account with a password For partners whose tools cannot use keys, pass `withPassword: true`: ```request query: | mutation { issueDeliveryAccount( input: { username: "coastline", displayName: "Coastline Sound", withPassword: true } ) { deliveryAccountPk password } } ``` The password is returned **once**. If it is lost, [reset it](https://docs.schema.fm/reference/delivery-accounts/#mutation-resetDeliveryPassword) — the old one stops working immediately. ## What your partner does Give them the host, port, username and password (or confirm their key), and have them upload each delivery — the XML and its media — as its own directory inside `upload/`. The directory is what ties a message to its files, so keep one delivery per directory: ```sh sftp -P 22 lanternfish@203.0.113.24 sftp> put -r 20260917_0196871402293 upload/ ``` Deliveries show up in [messages](https://docs.schema.fm/reference/deliveries/#query-messages), and anything still waiting for files in [blockedReleases](https://docs.schema.fm/reference/deliveries/#query-blockedReleases). ## Rotate a key Several keys can be live at once: 1. [addDeliveryKey](https://docs.schema.fm/reference/delivery-accounts/#mutation-addDeliveryKey) with the new key. 2. Have the partner confirm they can connect with it. 3. [revokeDeliveryKey](https://docs.schema.fm/reference/delivery-accounts/#mutation-revokeDeliveryKey) the old one, found with [deliveryAccountKeys](https://docs.schema.fm/reference/delivery-accounts/#query-deliveryAccountKeys). Key and password changes apply to the partner's next connection. ## Revoke an account ```request query: | mutation { revokeDeliveryAccount(deliveryAccountPk: 64) } ``` The account can no longer sign in. It stays in [deliveryAccounts](https://docs.schema.fm/reference/delivery-accounts/#query-deliveryAccounts) with `revokedAt` set, and everything it delivered keeps its history. ## Several catalogs Delivery accounts belong to the organization, since it has one endpoint. If your organization has more than one catalog, pass `catalogPk` when issuing an account to choose which catalog its deliveries land in. --- # Stream media > Pick an encoding, mint a short-lived signed URL for it, and pull it into your pipeline — or play a preview in a browser. Source: https://docs.schema.fm/guides/streaming-media/ ## Choose an encoding Every audio master you hold is measured and encoded moments after it lands. List what a track has with [encodings](https://docs.schema.fm/reference/resources/#Resource): ```request query: | query { release(upc: "0196871402293") { resources(kind: AUDIO) { resourcePk title encodings { encoding clip { startMs durationMs } original codec bitrateKbps lossless sha256 } } } } response: | { "data": { "release": { "resources": [ { "resourcePk": 771201, "title": "Signal Fire", "encodings": [ { "encoding": "original", "clip": null, "original": true, "codec": "pcm_s24le", "bitrateKbps": 4608, "lossless": true, "sha256": "9f2c4e0d1a7b3c58e2f6d9a0b4c7e1f3a5d8c2b6e9f0a3d7c1b5e8f2a4d6c9b0" }, { "encoding": "aac-256-v1", "clip": null, "original": false, "codec": "aac", "bitrateKbps": 256, "lossless": false, "sha256": "3e7a1c9f5b2d8e4a0c6f1b3d7e9a2c5f8b0d4e6a1c3f5b7d9e2a4c6f8b1d3e5a" }, { "encoding": "aac-128-v1", "clip": null, "original": false, "codec": "aac", "bitrateKbps": 128, "lossless": false, "sha256": "b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4d6" }, { "encoding": "aac-128-v1", "clip": { "startMs": 30000, "durationMs": 30000 }, "original": false, "codec": "aac", "bitrateKbps": 128, "lossless": false, "sha256": "c1e3a5b7d9f1c3e5a7b9d1f3c5e7a9b1d3f5c7e9a1b3d5f7c9e1a3b5d7f9c1e3" } ] } ] } } } ``` | Encoding | Made | What it is for | |---|---|---| | `aac-256` | for every master | The default stream. | | `aac-128` | for every master | Cellular, and auditioning. | | `aac-128`, `clip: true` | for every master | A 30-second preview. | | `flac` | for every master an organization with lossless media holds | The lossless pull. | | `mp3-128`, `mp3-320`, `mp3-v0`, `wav`, `alac` | on request | Download stores, legacy players. | | `original` | — | The bytes you delivered. | [encodingProfiles](https://docs.schema.fm/reference/encodings/#query-encodingProfiles) lists them all — read it rather than hardcoding names. A family (`aac-256`) follows the newest version; a pinned name (`aac-256-v1`) never changes. ## Get a signed URL Name the encoding to [url](https://docs.schema.fm/reference/resources/#Resource): ```request query: | query { release(upc: "0196871402293") { resources(kind: AUDIO) { title url(encoding: "aac-256", ttlSeconds: 600) } } } response: | { "data": { "release": { "resources": [ { "title": "Signal Fire", "url": "https://media.schema.fm/sha256/3e/7a/3e7a1c9f…?X-Amz-Expires=600&X-Amz-Signature=…" } ] } } } ``` The URL is an ordinary HTTPS link. `GET` it from your server and pull the file into your own pipeline, then check it against the encoding's `sha256`. Pass `download: true` and a browser saves it as `Artist - Title.m4a` instead of playing it. Artwork has no encodings: ask for `url(encoding: "original")` on `resources(kind: IMAGE)`, or for a cover at one of your organization's sizes with `artwork(size:)` — see [Size cover art](https://docs.schema.fm/guides/artwork-sizes.md). ## Which key may pull what The scope depends on what the file is, not on whether it is the original: | Scope | Reaches | |---|---| | `media_preview` | clips, and AAC 128 | | `media_read` | everything `media_preview` does, every other lossy encoding, and artwork | | `media_lossless` | FLAC, WAV, ALAC, and a lossless original — **organizations with lossless media only**: every enterprise organization, and others by arrangement | So a DSP's editorial tool can audition a whole catalog with a `media_preview` key that cannot pull a master-quality file. Lossless is checked against your organization's plan every time a URL is minted, not when the key was made. A master not yet analyzed counts as lossless. Every URL minted is logged — which key, which file, when — so a leaked link can be traced. The release does not have to be live: if the file is here, you can pull it, and what you do with an unreleased track is governed by your agreement with the label. ## Play a preview in a browser A signed URL works in an `