> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.royalti.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Catalog Import & Export

> Import catalog metadata from external providers through a staged review queue, and export your catalog to registered file formats

## 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.

<Warning>
  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](#the-staged-review-safety-model) below.
</Warning>

## Prerequisites

### Authentication

All endpoints require a Bearer token for a tenant user with the `admin` role or higher.

```javascript Node.js theme={null}
const token = 'your_jwt_token';
const headers = {
  'Authorization': `Bearer ${token}`,
  'Content-Type': 'application/json'
};
```

```bash cURL theme={null}
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

<Steps>
  <Step title="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.

    ```javascript Node.js theme={null}
    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: [...] } }]
    ```

    ```bash cURL theme={null}
    curl -X GET https://api.royalti.io/catalog-import/providers \
      -H "Authorization: Bearer $TOKEN"
    ```

    <Info>
      A provider missing credentials is silently omitted rather than erroring — the returned list is always "what you can actually use right now."
    </Info>
  </Step>

  <Step title="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).

    ```javascript Node.js theme={null}
    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'] }]
    ```

    ```bash cURL theme={null}
    curl -X GET "https://api.royalti.io/catalog-import/search?q=Fela%20Kuti&limit=10" \
      -H "Authorization: Bearer $TOKEN"
    ```

    <Note>
      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.
    </Note>
  </Step>

  <Step title="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.

    ```javascript Node.js theme={null}
    // 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);
    ```

    ```javascript Node.js theme={null}
    // 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']
      })
    });
    ```

    ```bash cURL theme={null}
    curl -X POST https://api.royalti.io/catalog-import/fetch \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"artistExternalId": "4kSvTBOm6QI0ivKlqZJfDe", "market": "US"}'
    ```

    <Warning>
      ISRC and UPC lists are capped at 500 items per import. Omitting all three inputs returns a `400`.
    </Warning>
  </Step>

  <Step title="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.

    ```javascript Node.js theme={null}
    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));
      }
    }
    ```

    ```bash cURL theme={null}
    curl -X GET https://api.royalti.io/catalog-import/job/$JOB_ID \
      -H "Authorization: Bearer $TOKEN"
    ```

    <Note>
      Jobs are scoped to the caller's tenant — polling a job id from another tenant returns `404`.
    </Note>
  </Step>

  <Step title="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.

    ```javascript Node.js theme={null}
    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);
    });
    ```

    ```bash cURL theme={null}
    curl -X GET https://api.royalti.io/catalog-import/batch/$BATCH_ID \
      -H "Authorization: Bearer $TOKEN"
    ```
  </Step>

  <Step title="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.

    ```javascript Node.js theme={null}
    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);
    ```

    ```bash cURL theme={null}
    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).

    <Warning>
      `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.
    </Warning>
  </Step>

  <Step title="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.

    ```javascript Node.js theme={null}
    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'
          }
        })
      }
    );
    ```

    ```bash cURL theme={null}
    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"}}'
    ```

    <Note>
      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`.
    </Note>
  </Step>

  <Step title="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`.

    ```javascript Node.js theme={null}
    // 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}`);
    ```

    ```javascript Node.js theme={null}
    // 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'] })
    });
    ```

    ```bash cURL theme={null}
    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.

    ```javascript Node.js theme={null}
    await fetch('https://api.royalti.io/catalog-import/reject', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ batchId })
    });
    ```

    ```bash cURL theme={null}
    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"]}'
    ```

    <Warning>
      Both `apply` and `reject` require `batchId` or a non-empty `itemIds[]` — omitting both returns a `400`.
    </Warning>
  </Step>
</Steps>

### 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.

```javascript Node.js theme={null}
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);
});
```

```bash cURL theme={null}
curl -X GET https://api.royalti.io/catalog/export/templates \
  -H "Authorization: Bearer $TOKEN"
```

<Info>
  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.
</Info>

### 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.

```javascript Node.js theme={null}
// 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);
}
```

```javascript Node.js theme={null}
// 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'
    }
  })
});
```

```bash cURL theme={null}
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:

```javascript Node.js theme={null}
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));
  }
}
```

```bash cURL theme={null}
curl -X GET https://api.royalti.io/download/$DOWNLOAD_ID/status \
  -H "Authorization: Bearer $TOKEN"
```

<Info>
  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`.
</Info>

### 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.

<Warning>
  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`.
</Warning>

## 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.

## Related Endpoints

### Catalog Import

* [List Active Providers](/api-reference/catalog-import/get-catalog-import-providers)
* [Search for an Artist](/api-reference/catalog-import/get-catalog-import-search)
* [Queue a Fetch Job](/api-reference/catalog-import/post-catalog-import-fetch)
* [Poll Job Status](/api-reference/catalog-import/get-catalog-import-job-id)
* [List Batch Items](/api-reference/catalog-import/get-catalog-import-batch-id)
* [Get Batch Diff](/api-reference/catalog-import/get-catalog-import-batch-id-diff)
* [Set Item Overrides](/api-reference/catalog-import/put-catalog-import-items-id)
* [Apply Staged Items](/api-reference/catalog-import/post-catalog-import-apply)
* [Reject Staged Items](/api-reference/catalog-import/post-catalog-import-reject)

### Catalog Export

* [List Export Templates](/api-reference/catalog-export/get-catalog-export-templates)
* [Run a Catalog Export](/api-reference/catalog-export/post-catalog-export)
