Catalogue Ingest API
Validation and gaps
Dry-run mode, the gap report, the validation report, and what we normalise.
Every response carries two reports about your data, and a count of every change we made to it. Neither report blocks the write. We always store works as you delivered them.
Dry-run mode#
To validate without a write, use either the query parameter ?dry_run=true or the body field
"mode": "validate".
A dry-run returns 200, not 201, with:
work_ids: []- every
results[].work_idset tonull - the same
gapsandvalidationreports a real commit would return
We store nothing, create no catalogue, create no artist mapping, and ignore Idempotency-Key. Use it to
check data quality before you commit.
The response#
{
"catalog_id": "cat-a1b2c3d4",
"catalog_name": "Q3 Catalogue Sync",
"mode": "commit",
"works_created": 2,
"works_updated": 0,
"works_skipped": 0,
"work_ids": ["REC-2026-00001", "REC-2026-00002"],
"results": [
{
"work_index": 0, "client_work_ref": "ACME-0001", "work_id": "REC-2026-00001", "action": "created",
"status": "stored_clean", "holds": [], "issues": [], "missing": [],
"isrc": { "status": "confirmed", "recordings": [{ "title": "Midnight Drive", "artist": "Your Artist", "release_date": "2026-01-16" }] }
},
{
"work_index": 1, "client_work_ref": "ACME-0002", "work_id": "REC-2026-00002", "action": "created",
"status": "held", "holds": ["isrc_verdict"],
"issues": [
{ "code": "isrc_known_recording_differs", "field": "isrc", "severity": "advisory",
"message": "ISRC MXA3V2459561 is listed on Apple Music for \"Through The Pain\" by Jude Black (2025-12-09), and this work is \"Ancestral Voices\". Check which recording the code belongs to, and send the right code." }
],
"missing": ["iswc"],
"isrc": { "status": "mismatch", "recordings": [{ "title": "Through The Pain", "artist": "Jude Black", "release_date": "2025-12-09" }] }
}
],
"summary": {
"works": { "stored_clean": 1, "stored_with_issues": 0, "held": 1, "skipped": 0 },
"by_code": { "isrc_known_recording_differs": 1 },
"by_severity": { "blocking": 0, "advisory": 1 },
"sources_checked": { "bwarm": true, "ipiqs": true, "apple": true }
},
"gaps": [
{ "work_index": 1, "client_work_ref": "ACME-0002", "title": "Ancestral Voices", "missing_fields": ["iswc"] }
],
"validation": {
"total_works": 2,
"works_flagged": 1,
"attestation_ran": true,
"apple_ran": true,
"issues": [
{
"work_index": 1,
"client_work_ref": "ACME-0002",
"work_id": "REC-2026-00002",
"title": "Ancestral Voices",
"fields": [
{ "field_name": "isrc", "reason": "conflict", "code": "isrc_known_recording_differs", "severity": "advisory",
"suggested_action": "ISRC MXA3V2459561 is listed on Apple Music for \"Through The Pain\" by Jude Black (2025-12-09), and this work is \"Ancestral Voices\". Check which recording the code belongs to, and send the right code." }
]
}
]
},
"cleansed_identifiers": { "ISRC": 0 },
"roster_adjusted": 0,
"prose_stripped": 0,
"canonicalised": 1
}
Read results[] and summary. Each entry in results[] is one work with everything we know about it. Correlate it by work_index, and by client_work_ref where you sent one. gaps and validation.issues are kept for compatibility and say the same things by index. work_ids is kept too, lists created works only, and is not in request order. Read results[].work_id instead.
| Field | Type | Description |
|---|---|---|
catalog_id | string | The catalogue the works landed in. Empty in a dry-run that would create a new catalogue. |
catalog_name | string | Its name. |
mode | enum | "commit" or "validate". |
works_created | integer | New works created. |
works_updated | integer | Existing works updated, matched by client_work_ref. |
works_skipped | integer | Works we did not write. Two rules reach this count. See below. |
work_ids | string[] | Ids of created works only, formatted REC-YYYY-NNNNN. Empty in a dry-run. Not in request order, so never join it by position. Kept for compatibility. Read results[].work_id. |
results | array | One object per work, in request order. Build your parser on this. |
results[].work_index | integer | The position in your request. Correlate on this. client_work_ref is a second key only where you sent one. |
results[].status | enum | stored_clean, stored_with_issues, held or skipped. A hold outranks an issue. |
results[].holds | string[] | What stops a filing: withdrawn, release_pending, composition_conflict, isrc_verdict, third_party_publisher. The work is stored. |
results[].issues | array | The validation codes on this work. code is the contract. message may change. |
results[].missing | string[] | Fields absent from this work. The same vocabulary as gaps[].missing_fields. |
results[].hold_details | array | One entry per hold. A third_party_publisher entry names the held writer and their administrator. See Readiness. |
results[].readiness | object | Ready for the MLC and for the PROs, with what you can send. Absent on a skipped entry. See Readiness. |
results[].isrc | object | What Apple Music holds under the ISRC you sent, and a verdict. See "The ISRC verdict" below. |
summary | object | Counts by status, code and severity, and sources_checked. A false in sources_checked means not checked, never clear. |
gaps | array | The presence report, by index. Kept for compatibility. Read results[].missing. |
validation | object | The correctness report, by index. Kept for compatibility. Read results[].issues. |
cleansed_identifiers | object | Counts of identifier cells we blanked, keyed by type. |
roster_adjusted | integer | Works whose writer roster we split or de-duplicated. |
prose_stripped | integer | Works we removed free text from. |
canonicalised | integer | Fields we normalised to canonical form. |
The ISRC verdict#
Every stored entry in results[] carries an isrc object. A skipped entry does not. The object says what Apple Music lists under the code you sent.
status | Meaning |
|---|---|
confirmed | Apple lists the code on this recording only. |
shared | Apple lists the code on this recording and on another. The work waits, and goes to no composition file. |
mismatch | Apple lists the code on a different recording. The work waits, and goes to no composition file. |
not_found | Apple has no recording under the code. This is normal before a release goes live, and for a day after, because our copy of the catalogue reloads daily. It is not a pass. |
not_checked | The source did not answer, or the work carries no artist to compare. Read it as unknown, never as confirmed. |
recordings[] names every recording under the code, oldest first, with its title, artist and release date.
A shared or mismatch verdict also raises isrc_shared_by_recordings or isrc_known_recording_differs in validation. Both are advisory. The work is stored. A registration on that code would land on the other recording, so the work waits until the code is right. It goes to none of the composition files: MLC, EBR, CWR and AllTrack. Send the work again with the right code and the wait ends.
validation.apple_ran is false when the source did not answer. Every verdict is then not_checked.
Why a work is skipped#
A skip is always reported. Every skipped work appears in results as
{ client_work_ref, work_id: null, action: "skipped" }. It is also counted in works_skipped.
Two rules produce a skip, and they behave differently.
1. ISRC identity. A work with no client_work_ref takes its identity from
recording.isrc. If that ISRC was already placed, the work is skipped. Already placed means earlier
in the same request, or already in your catalogue. This rule applies on every ingest, including
the first one that creates the catalogue.
An ISRC identifies a recording, which is the right grain for these rows. The MLC song code is deliberately not part of the identity. Two rows that share an ISRC but carry different song codes are two registrations, and they stay separate.
2. Title collision. A work with no client_work_ref is also skipped on an append. The request
must name an existing catalogue, and that catalogue must already hold the work's primary_title.
Corrections need a work ref#
client_work_ref is the upsert key. It is the only way to update a work you have already sent.
A correction sent without a ref but with a stable ISRC is skipped, not applied. Rule 1
reads the ISRC as "this recording is already here". The response says skipped rather than
updated, which is your signal that the ref is missing.
Send the same client_work_ref you sent originally. The work is then matched and updated, and it
counts in works_updated.
gaps against validation#
The two reports answer different questions.
gaps | validation | |
|---|---|---|
| Question | What is missing? | What is present but wrong? |
| Example | No ISWC on this work. | This IPI belongs to a different person. |
| Blocks the write? | No | No |
gaps[].missing_fields can hold: isrc, iswc, writers, writer_ipi, writer_ownership_pct,
publishers, publisher_collection_share.
validation.issues lists only flagged works. Each fields[] entry is
{ field_name, reason, code, suggested_action }.
Validation codes#
Every issue carries a severity. blocking refuses the request under strict enforcement.
advisory never refuses. See "Enforcement" below.
code | severity | Meaning |
|---|---|---|
ipi_format | blocking | The IPI is not a 9 to 11 digit number. |
ipi_check_digit | blocking | The IPI is the right length but fails its check digit. It is a typo. We do not check a 9 digit value, so we never reject one. |
ipi_unregistered | advisory | The writer IPI is in no registry. It is a typo or an invention. |
ipi_wrong_party | advisory | The writer IPI is registered, but to a different party. |
ipi_role_mismatch | blocking | The IPI belongs to a company, so no writer can hold it. |
pro_unknown | blocking | The PRO affiliation is not a CISAC society. Send the society name or code, or one of NA, none, unaffiliated. |
iswc_format | blocking | The ISWC is present but not valid. |
upc_format | blocking | A UPC arrived that is not 12 or 13 digits. |
isrc_format | blocking | The ISRC is present but not valid. |
multi_value_cell | advisory | One identifier cell held several values. We split them out and blanked the field. |
writer_share_over | blocking | Writer ownership shares add up to more than 100%. |
writer_share_under | blocking | Every writer states a share, and together they are under 100%. |
writer_share_missing | blocking | At least one writer states no share, so the total is unknown. We do not read an absent share as zero. |
collection_share_over | blocking | Publisher collection shares add up to more than 100%. |
writer_name_missing | blocking | A writer arrived with no first name and no last name. Nothing identifies the party, and an empty name matches every other empty name. |
no_writers | blocking | The work names no writers, so no composition claim can be filed from it. |
controlled_writer_unidentified | advisory | A writer is marked as controlled but carries no IPI. A society files the work without one, so we register it and report the gap. Only the writer share of a performance royalty waits on the IPI. |
writer_controlled_absent | advisory | A writer arrived with no writer_controlled value, so nobody has answered whether we administer their share. We report it as unanswered. The work does not register until the writer carries true. |
authorization_missing | blocking | A controlled writer carries no evidence of their opt-in. Send authorized_on and authorization_ref. |
roster_wipe | blocking | An update carries no writers and would erase the roster already stored. |
roster_shrink | advisory | An update carries fewer writers or publishers than we hold, so the difference would be removed. Your payload replaces the stored roster, so we apply the removal. This is a notice, not a refusal: we cannot tell a deliberate correction from a partial payload. Send the full roster if the removal was not intended. |
publisher_ipi_is_writer | blocking | A publisher row carries a writer's own IPI. A person's number cannot name a publisher. It passes every format check, so we refuse it here rather than file it. Send the publisher's IPI, or none. |
controlled_writer_one_name | advisory | A controlled writer arrives with one name and no IPI. A society files the writer share under a legal first and last name. We stay silent when the writer carries an IPI, because the registry holds the legal name. |
writer_has_administrator | advisory | A writer you collect for is linked to a publisher with role AM, or to a known administrator by name. That writer already has a collector. Keep the flag only when the writer leaves them. |
publisher_share_over_writer | advisory | A publisher row claims more than its linked writer owns: collection above the writer's share, or performance above half of it. All shares are on the whole-work scale. See share semantics, "Scale and ceiling". |
publisher_name_absent | advisory | A publisher arrived with a blank name. A publisher with no name and no IPI has no exportable identity, so we do not store the row. Any PR share it carried goes with it. |
isrc_already_held | advisory | The ISRC is already on file under another of your references, or arrived twice in this request. One recording on two releases is one work. We key a work on your reference, so we store a second work. Send it under the existing reference, or tell us it is a different recording. |
isrc_known_recording_differs | advisory | Apple Music lists the ISRC on a different recording. The message names it. The work is stored. It goes to no MLC, EBR, CWR or AllTrack file until the code is right, because a registration on that code lands on the other recording. |
isrc_shared_by_recordings | advisory | Apple Music lists the ISRC on this recording and on another. The work is stored. It goes to no MLC, EBR, CWR or AllTrack file until each recording has its own code. |
An absent share and a low share are different answers. If one writer of two states 50% and the
other states nothing, the split is unknown, not 50%. We report writer_share_missing, never
writer_share_under, because reading the absent share as zero would say the work is half unclaimed
when nobody has told us anything.
Enforcement#
By default we store every work and report every problem. That is enforcement: "warn", and it is
what your integration does today.
Send enforcement: "strict" on a request to have us refuse it instead. We check the blocking issues
before anything is written, so a refused request stores nothing. The 400 names the work index and
the fields, and the response body's enforcement.blocking_issue_count tells you how many issues
would refuse you if you switched.
{ "enforcement": "strict", "works": [ ... ] }
Use it in your own tests before you commit to it. Nothing changes for you until you send the field, or until we agree a strict default for your account and set it.
ipi_unregistered and ipi_wrong_party are advisory on purpose. Both say we are unsure who the
number belongs to, and a person settles that. Neither refuses your request.
ipi_role_mismatch is the one registry check that refuses. It says the registry files the number to
a company, so no writer can hold it under any reading. There is nothing to settle. We refuse only
when the registry answers. If we cannot reach it, we get no answer, we raise no issue, and we refuse
nothing.
attestation_ran is false when we could not reach the registry. Format and share checks still run.
Only the registry checks, ipi_unregistered, ipi_wrong_party and ipi_role_mismatch, are skipped.
An ipi_wrong_party result deserves attention. A valid IPI that belongs to somebody else pays the wrong
writer, and nothing about the file looks wrong.
The ipi_wrong_party message names the registered party only when your writer's last name equals the
registered surname. Otherwise it says a different party. A sender who has the wrong number must not
learn the legal name of a stranger. The IPI match endpoint uses the same rule for suggested_name.
What we change#
We normalise what you send, and we report the changes as counts. roster_adjusted, prose_stripped, canonicalised and cleansed_identifiers tell you how many works or fields we touched, not which ones. Use mode: "validate" to see the same counts before you commit.
| We do this | We report it in |
|---|---|
Divide shares by 100 on store, so 50 becomes 0.5. Lossless. | Not counted. It applies to every share. |
Canonicalise ISRC, ISWC and duration. duration becomes HH:MM:SS. | canonicalised |
| Blank an identifier cell that holds prose or junk, and report it as a gap. | cleansed_identifiers |
| Remove free-text instructions that leaked into structured fields. | prose_stripped |
| Split one cell listing several writers, and merge duplicates. | roster_adjusted |
Because we split and de-duplicate rosters, the stored writer count can differ from the count you sent.
roster_adjusted tells you how many works that affected.