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_id set to null
  • the same gaps and validation reports 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#

json
{
  "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
}
FieldTypeDescription
catalog_idstringThe catalogue the works landed in. Empty in a dry-run that would create a new catalogue.
catalog_namestringIts name.
modeenum"commit" or "validate".
works_createdintegerNew works created.
works_updatedintegerExisting works updated, matched by client_work_ref.
works_skippedintegerWorks we did not write. Two rules reach this count. See below.
work_idsstring[]Ids of created works, formatted REC-YYYY-NNNNN. Empty in a dry-run.
resultsarrayPer-work echo of { client_work_ref, work_id, action }. Reconcile your id to ours here.
gapsarrayThe presence report.
validationobjectThe correctness report.
cleansed_identifiersobjectCounts of identifier cells we blanked, keyed by type.
roster_adjustedintegerWorks whose writer roster we split or de-duplicated.
prose_strippedintegerWorks we removed free text from.
canonicalisedintegerFields 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.

gapsvalidation
QuestionWhat is missing?What is present but wrong?
ExampleNo ISWC on this work.This IPI belongs to a different person.
Blocks the write?NoNo

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.

codeseverityMeaning
ipi_formatblockingThe IPI is not a 9 to 11 digit number.
ipi_check_digitblockingThe 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_unregisteredadvisoryThe writer IPI is in no registry. It is a typo or an invention.
ipi_wrong_partyadvisoryThe writer IPI is registered, but to a different party.
ipi_role_mismatchblockingThe IPI belongs to a company, so no writer can hold it.
pro_unknownblockingThe PRO affiliation is not a CISAC society. Send the society name or code, or one of NA, none, unaffiliated.
iswc_formatblockingThe ISWC is present but not valid.
isrc_formatblockingThe ISRC is present but not valid.
multi_value_celladvisoryOne identifier cell held several values. We split them out and blanked the field.
writer_share_overblockingWriter ownership shares add up to more than 100%.
writer_share_underblockingEvery writer states a share, and together they are under 100%.
writer_share_missingblockingAt least one writer states no share, so the total is unknown. We do not read an absent share as zero.
collection_share_overblockingPublisher collection shares add up to more than 100%.
writer_name_missingblockingA writer arrived with no first name and no last name. Nothing identifies the party, and an empty name matches every other empty name.
no_writersblockingThe work names no writers, so no composition claim can be filed from it.
controlled_writer_unidentifiedadvisoryA 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_absentblockingA writer arrived with no writer_controlled value, so nobody has said whether we administer their share.
authorization_missingblockingA controlled writer carries no evidence of their opt-in. Send authorized_on and authorization_ref.
roster_wipeblockingAn update carries no writers and would erase the roster already stored.
roster_shrinkadvisoryAn 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_absentadvisoryA 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.

json
{ "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 thisWe 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.