Skip to main content

Overview

The Catalog Import & Export API covers two related but independent tenant-scoped tools:
  • Catalog Import — search an artist on a configured metadata provider, fetch and merge cross-provider metadata into a staged review queue, inspect a diff against your existing catalog, optionally correct individual rows, then explicitly apply or reject the batch.
  • Catalog Export — render your catalog (assets, products, artists, labels, splits, releases, or specialized formats like a CWR-workstation import file) from a registered set of export templates, either as a synchronous file download or an async job for large exports.
Both surfaces are admin-only — every route requires the tenant admin role or higher.
Catalog Import never writes to your catalog automatically. Fetching and staging metadata is a read/stage-only operation; the only endpoint that creates or updates Asset/Product rows is POST /catalog-import/apply, and that call is always an explicit, reviewer-initiated action. See The Staged-Review Safety Model below.

Prerequisites

Authentication

All endpoints require a Bearer token for a tenant user with the admin role or higher.
Node.js
const token = 'your_jwt_token';
const headers = {
  'Authorization': `Bearer ${token}`,
  'Content-Type': 'application/json'
};
cURL
export TOKEN="your_jwt_token"

Active metadata providers

Catalog Import’s provider set is config-driven per deployment (METADATA_IMPORT_PROVIDERS) — a provider is active only if it’s configured and has credentials. You don’t need to name a provider up front; GET /catalog-import/providers tells you what’s currently available and what each one can do.

Catalog Import

Import Workflow

1

List active providers (optional)

See which metadata providers are configured for this deployment and what each one supports — free-text artist search, a full artist-catalog walk, ISRC lookup, and UPC lookup.
Node.js
const response = await fetch('https://api.royalti.io/catalog-import/providers', {
  headers: { 'Authorization': `Bearer ${token}` }
});
const { data } = await response.json();
console.log(data.providers);
// [{ id: 'musicbrainz', capabilities: { searchArtist: true, artistCatalog: true, byIsrc: true, byUpc: true, fields: [...] } }]
cURL
curl -X GET https://api.royalti.io/catalog-import/providers \
  -H "Authorization: Bearer $TOKEN"
A provider missing credentials is silently omitted rather than erroring — the returned list is always “what you can actually use right now.”
2

Search for an artist

Resolve a human-readable artist name to the provider-native artistExternalId that POST /fetch needs. Search runs against whichever active provider supports searchArtist (the primary catalog provider is preferred).
Node.js
const response = await fetch(
  'https://api.royalti.io/catalog-import/search?q=Fela+Kuti&limit=10',
  { headers: { 'Authorization': `Bearer ${token}` } }
);
const { data } = await response.json();
console.log(data.artists);
// [{ name: 'Fela Kuti', externalIds: { musicbrainz: '...' }, urls: {...}, followers: 1200000, genres: ['Afrobeat'] }]
cURL
curl -X GET "https://api.royalti.io/catalog-import/search?q=Fela%20Kuti&limit=10" \
  -H "Authorization: Bearer $TOKEN"
If q looks like an ISRC, it’s resolved to the recording’s credited artists first, then those names are searched — an identifier-shaped free-text query would otherwise return unrelated results. If q looks like a UPC/barcode, an empty result is returned since barcodes have no artist identity.
3

Queue a fetch + staging job

Queue a background job that fetches metadata from every active provider for the given artist and/or explicit identifiers, merges the results across providers with provenance tracking, and stages the outcome as CatalogImportItem rows. This call never writes to Asset/Product — it only returns a jobId and batchId to poll and review.At least one of artistExternalId, isrcs, or upcs is required. Supplying artistExternalId triggers a full artist-catalog walk on the primary catalog provider; every ISRC/UPC discovered during that walk is automatically queued for cross-provider enrichment alongside anything you pass explicitly.
Node.js
// By artist — full catalog walk
const response = await fetch('https://api.royalti.io/catalog-import/fetch', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    artistExternalId: '4kSvTBOm6QI0ivKlqZJfDe',
    market: 'US'
  })
});
const { data } = await response.json();
console.log('Job:', data.jobId, 'Batch:', data.batchId);
Node.js
// By explicit identifiers instead of an artist walk
const response = await fetch('https://api.royalti.io/catalog-import/fetch', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    isrcs: ['USRC17607839', 'GBAYE0601498'],
    upcs: ['00602435054101']
  })
});
cURL
curl -X POST https://api.royalti.io/catalog-import/fetch \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"artistExternalId": "4kSvTBOm6QI0ivKlqZJfDe", "market": "US"}'
ISRC and UPC lists are capped at 500 items per import. Omitting all three inputs returns a 400.
4

Poll the job status

Poll until the job’s BullMQ state reaches completed. The result field carries the staging outcome (staged asset/product counts, skipped count, diff, merge stats) once settled.
Node.js
async function pollJob(jobId) {
  while (true) {
    const response = await fetch(`https://api.royalti.io/catalog-import/job/${jobId}`, {
      headers: { 'Authorization': `Bearer ${token}` }
    });
    const { data } = await response.json();
    if (data.state === 'completed') return data.result;
    if (data.state === 'failed') throw new Error(data.failedReason);
    await new Promise(r => setTimeout(r, 2000));
  }
}
cURL
curl -X GET https://api.royalti.io/catalog-import/job/$JOB_ID \
  -H "Authorization: Bearer $TOKEN"
Jobs are scoped to the caller’s tenant — polling a job id from another tenant returns 404.
5

Review the staged batch

List every CatalogImportItem staged under the batch — every status (pending, approved, rejected, imported, failed), ordered by type then creation time. This is a pure read.
Node.js
const response = await fetch(
  `https://api.royalti.io/catalog-import/batch/${batchId}`,
  { headers: { 'Authorization': `Bearer ${token}` } }
);
const { data } = await response.json();
console.log(`${data.count} staged items`);
data.items.forEach(item => {
  console.log(item.action, item.type, item.enrichedTitle, item.enrichmentConfidence);
});
cURL
curl -X GET https://api.royalti.io/catalog-import/batch/$BATCH_ID \
  -H "Authorization: Bearer $TOKEN"
6

Review the diff against your current catalog

Recompute the diff between the batch’s pending staged rows and your tenant’s CURRENT Asset/Product catalog. This is useful right before applying, since the catalog may have changed since the original fetch. It performs SELECTs only — never writes.
Node.js
const response = await fetch(
  `https://api.royalti.io/catalog-import/batch/${batchId}/diff`,
  { headers: { 'Authorization': `Bearer ${token}` } }
);
const { data } = await response.json();
console.log('Creates:', data.diff.creates.length);
console.log('Updates:', data.diff.updates.length);
console.log('Conflicts:', data.diff.conflicts.length);
console.log('Skips:', data.diff.skips.length);
cURL
curl -X GET https://api.royalti.io/catalog-import/batch/$BATCH_ID/diff \
  -H "Authorization: Bearer $TOKEN"
Each diff entry carries kind (create, update, conflict, or skip), the matched entity (recording or release), a matchKey (isrc/upc/fuzzy), and a changes[] list with per-field current vs. incoming values and provenance (which provider’s value won, and any disagreeing sources).
conflict rows — where an incoming field disagrees with a non-empty existing value — do not auto-apply. They stay pending until a reviewer resolves them with an override in the next step.
7

Set reviewer overrides (optional)

Correct a single pending staged item before applying it — for example to resolve a conflict diff entry, fix a title, or set the artist-role map. finalData is merged into the item’s existing finalData (not replaced); only a fixed field allowlist is accepted (title, displayArtist, mainGenre, explicit, recordingDate, label, format, albumArt, iswc, releaseDate, version, catalogNumber) — any other key is silently dropped. artists replaces the item’s artist-role map outright.
Node.js
const response = await fetch(
  `https://api.royalti.io/catalog-import/items/${itemId}`,
  {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      finalData: {
        title: 'Corrected Title',
        iswc: 'T-034524680-1'
      },
      artists: {
        'Jane Doe': 'primary',
        'Guest Artist': 'featuring'
      }
    })
  }
);
cURL
curl -X PUT https://api.royalti.io/catalog-import/items/$ITEM_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"finalData": {"title": "Corrected Title", "iswc": "T-034524680-1"}}'
At least one of finalData or artists is required. The item must still be pending and owned by your tenant, or you’ll get a 404.
8

Apply or reject

Apply is the only write path in the importer. Resolve the set of pending staged items by batchId and/or itemIds (scoped to your tenant), and each one is created/updated against Asset/Product in its own transaction. Every processed item is marked imported (or failed, with importError set) and stamped with reviewedBy/reviewedAt.
Node.js
// Apply every pending item in a batch
const applyResponse = await fetch('https://api.royalti.io/catalog-import/apply', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ batchId })
});
const { data } = await applyResponse.json();
console.log(`Imported: ${data.imported}, Failed: ${data.failed}`);
Node.js
// Or apply a hand-picked subset of items
await fetch('https://api.royalti.io/catalog-import/apply', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ itemIds: ['3fa85f64-5717-4562-b3fc-2c963f66afa6'] })
});
cURL
curl -X POST https://api.royalti.io/catalog-import/apply \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"batchId": "metadata-import-8f14e45f-ceea-4d5e-8b3a-0a1b2c3d4e5f"}'
To decline a batch instead, call reject with the same request shape. Rejected rows are never applied, and rejecting is not permanent for the underlying identifier — a later import proposing the same ISRC/UPC re-stages it as a fresh row rather than being deduplicated away.
Node.js
await fetch('https://api.royalti.io/catalog-import/reject', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ batchId })
});
cURL
curl -X POST https://api.royalti.io/catalog-import/reject \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"itemIds": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}'
Both apply and reject require batchId or a non-empty itemIds[] — omitting both returns a 400.

The Staged-Review Safety Model

Catalog Import is built around a strict read-then-write separation so a fetch can never silently mutate your catalog:
  • Fetch only ever stages. POST /catalog-import/fetch writes exclusively to CatalogImportItem rows. It never touches Asset or Product.
  • Apply is the sole write path. POST /catalog-import/apply is the only endpoint in the importer that creates or updates catalog rows, and it only runs when a reviewer explicitly calls it — there is no auto-apply.
  • Per-item transactions. Apply processes each staged item in its own transaction. A failure on one item (marked failed with importError set) does not roll back or block the others.
  • Idempotent re-application. Unique indexes on your tenant’s ISRC/UPC roster, combined with apply only ever touching rows with status: pending, mean re-calling apply on an already-applied batch is a safe no-op (imported: 0, failed: 0) rather than creating duplicates.
  • Deduplicated staging. New fetches are deduplicated against already-pending or already-imported rows by ISRC/UPC before insert, so re-running the same import doesn’t pile up redundant staged rows.
  • Conflicts require a human decision. A diff entry of kind: conflict — an incoming value disagreeing with a non-empty existing value — never auto-applies. It stays pending until a reviewer sets an override via PUT /items/{id}.

Catalog Export

List registered export templates

Export templates are a code-defined registry, not a database table — the deployment ships with a fixed set of templates (a CWR-workstation import format, distributor formats, and CSV/XLSX exports for assets, products, artists, labels, splits, and releases). GET /catalog/export/templates lists what’s currently registered, including each template’s accepted options and supported output formats, so you can populate a template picker before running an export.
Node.js
const response = await fetch('https://api.royalti.io/catalog/export/templates', {
  headers: { 'Authorization': `Bearer ${token}` }
});
const { data: templates } = await response.json();
templates.forEach(t => {
  console.log(t.id, t.label, t.entity, t.formats, t.defaultFormat);
});
cURL
curl -X GET https://api.royalti.io/catalog/export/templates \
  -H "Authorization: Bearer $TOKEN"
This registry is distinct from the member-accessible generic ?export=csv query parameter available on existing list endpoints, which remains untouched. Use the template registry when you need a specialized or multi-entity export format.

Run an export

POST /catalog/export resolves template from the registry, validates options against that specific template’s own options schema, then does one of two things depending on size:
  • Row estimate ≤ 5000 (the common case): the rendered file streams back synchronously with a Content-Disposition attachment header — 200 with the file body.
  • Row estimate > 5000: a Download record is created and an async render job is queued instead — 202 with a downloadId to poll.
Node.js
// Synchronous export — small/typical tenant roster
const response = await fetch('https://api.royalti.io/catalog/export', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ template: 'assets' })
});

if (response.status === 200) {
  const blob = await response.blob();
  // response streamed directly — save or pipe the file
} else if (response.status === 202) {
  const { data } = await response.json();
  console.log('Queued as download:', data.downloadId);
}
Node.js
// Template-specific options — e.g. the CWR-workstation export with an
// explicit basis and publisher override
const response = await fetch('https://api.royalti.io/catalog/export', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    template: 'backbeat',
    options: {
      basis: 'works',
      publisherId: '8f14e45f-ceea-4d5e-8b3a-0a1b2c3d4e5f',
      catalogueName: 'Afrosounds Catalogue'
    }
  })
});
cURL
curl -X POST https://api.royalti.io/catalog/export \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"template": "assets"}' \
  --output assets-export.csv
If your export is queued asynchronously, poll the same download endpoint used for royalty and accounting reports:
Node.js
async function pollDownload(downloadId) {
  while (true) {
    const response = await fetch(`https://api.royalti.io/download/${downloadId}/status`, {
      headers: { 'Authorization': `Bearer ${token}` }
    });
    const { data } = await response.json();
    if (data.status === 'completed') return data;
    if (data.status === 'failed') throw new Error('Export failed');
    await new Promise(r => setTimeout(r, 3000));
  }
}
cURL
curl -X GET https://api.royalti.io/download/$DOWNLOAD_ID/status \
  -H "Authorization: Bearer $TOKEN"
Catalog exports share the Download model with royalty/accounting reports (type: report, tagged metadata.exportKind: catalog), so they also show up alongside those in GET /download/list.

Template options

Options are per-template — unknown or irrelevant keys are ignored by the resolved template’s own options schema:
  • ids (string array): restrict the export to specific entity ids; omit to export the full tenant roster.
  • format (csv or xlsx): must be one of the resolved template’s supported formats; defaults to the template’s defaultFormat.
  • basis (works or assets, CWR-workstation template only): overrides the auto-detected basis — works if the tenant has any CWRWork rows, otherwise assets.
  • publisherId (uuid, CWR-workstation template only): publisher used for the publisher block; defaults to the tenant’s publisherType=original publisher.
  • catalogueName (string, CWR-workstation template only): fallback catalogue name when a work has neither libraryCatalogueName nor metadata.origin.library set.
Requesting an unregistered template id returns a 404. Requesting a format the resolved template doesn’t support, or a template-specific option validation failure, returns a 400.

Best Practices

  • Search before you fetch. Use GET /catalog-import/search to resolve an artist to the correct artistExternalId rather than guessing — a mismatched id triggers a catalog walk against the wrong artist.
  • Re-check the diff right before applying. The diff at fetch time can go stale if your catalog changes in the interval — call GET /batch/{batchId}/diff again immediately before apply on any batch you’re not applying right away.
  • Resolve every conflict explicitly. Don’t skip kind: conflict diff entries — they represent a real disagreement between an incoming value and your existing data, and they will not be included until you set a finalData override.
  • Treat apply as safe to retry. Because apply is idempotent against pending rows, you can safely re-call it after a partial failure or a network timeout without risking duplicate catalog rows.
  • Check formats before building an export UI. Not every template supports both csv and xlsx — read each template’s formats/defaultFormat from GET /catalog/export/templates rather than hardcoding.
  • Handle both export response shapes. A client calling POST /catalog/export must handle both the synchronous 200 file stream and the asynchronous 202 + poll path — which one you get depends on the tenant’s catalog size at request time, not on anything you control in the request.

Catalog Import

Catalog Export