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": 1,
"works_updated": 0,
"works_skipped": 0,
"work_ids": ["REC-2026-00001"],
"results": [
{ "client_work_ref": "ACME-0001", "work_id": "REC-2026-00001", "action": "created" }
],
"gaps": [
{ "work_index": 0, "title": "Midnight Drive", "missing_fields": ["iswc"] }
],
"validation": {
"total_works": 1,
"works_flagged": 1,
"attestation_ran": true,
"issues": [
{
"work_index": 0,
"work_id": "REC-2026-00001",
"title": "Midnight Drive",
"fields": [
{
"field_name": "writer_ipi",
"reason": "low_confidence",
"code": "ipi_wrong_party",
"suggested_action": "IPI \"00123456789\" is registered to SAM OKONKWO, not Jordan Reyes: check this is the right writer."
}
]
}
]
},
"cleansed_identifiers": { "ISRC": 0 },
"roster_adjusted": 0,
"prose_stripped": 0,
"canonicalised": 1
}
| 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, formatted REC-YYYY-NNNNN. Empty in a dry-run. |
results | array | Per-work echo of { client_work_ref, work_id, action }. Reconcile your id to ours here. |
gaps | array | The presence report. |
validation | object | The correctness report. |
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. |
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. |
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 | blocking | A writer arrived with no writer_controlled value, so nobody has said whether we administer their share. |
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_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. |
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.
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.