# Catalogue Ingest API

Send your catalogue of musical works into TakeRecord over HTTPS. This is an inbound-only write API: you POST works (titles, recordings, writers, publishers, identifiers) and TakeRecord validates, normalises, and stores them against your account so they can be prepared for registration (MLC, PROs, and other destinations). There are no read or export endpoints on this surface; you retrieve outputs from the TakeRecord app.

**Base URL:** `https://ingest.takerecord.com`

## Overview

- **What it is:** a single endpoint, `POST /api/v1/catalogue/works`, that upserts one or more works into a catalogue.
- **Inbound only:** the API accepts your data. It does not return your stored catalogue, and there is no GET/list/download here.
- **Strict and honest:** the request schema is strict (a misspelled field is rejected, not ignored), shares are validated, and every transformation the pipeline applies to your data is reported back to you in the response.
- **Idempotent commits:** retries are safe when you send an `Idempotency-Key`.

---

## Getting your API key

API keys are created in the TakeRecord app and are scoped to a single client (your account). To create one:

1. Open **Settings**.
2. Go to the **API keys** section.
3. Click **Manage API keys**.
4. Click **Create key** and give it a name.
5. Copy the secret. It is shown **once**, at creation time, and starts with `trk_live_`. Store it somewhere safe (a secrets manager); if you lose it, revoke it and create a new one.

The key resolves your client server-side, so you never send a client id in normal use. Treat the key like a password: anyone holding it can write to your catalogue.

---

## Authentication

Send the key as a Bearer token:

```
Authorization: Bearer trk_live_...
```

- The key identifies your client, so **no `x-client-id` header is required**.
- If you do send `x-client-id` (or a body `client_id`), it **must** equal the client bound to the key. A mismatch is rejected (`403` for the header, `400` for the body value).
- A missing, invalid, expired, or revoked key returns `401`.
- Only a per-client `trk_` key works. Generic or shared internal keys are rejected with `403`.

---

## Base URL

```
https://ingest.takerecord.com
```

All requests are HTTPS. The only endpoint on this surface is `POST /api/v1/catalogue/works`.

---

## Rate limits

- **60 requests per minute per key.** Exceeding this returns `429`; wait and retry with backoff.
- Every response (success or error) includes an **`x-request-id`** header. Quote it when contacting support.

---

## Idempotency

Commits (not dry-runs) accept an `Idempotency-Key` header so a network retry never double-writes.

- Send a **unique** `Idempotency-Key` per logical request (a UUID is ideal). When paging a large catalogue, use **one key per chunk**.
- **Same key + same body:** the original stored response is replayed, with header `Idempotent-Replay: true`. Your retry is a no-op that returns the first result.
- **Same key + a request still processing:** `409`. Retry shortly.
- **Same key + a different body:** `422`. Keys are bound to the exact body they first ran; reusing one with changed content is an error.
- **Retention:** keys are remembered for **24 hours**.

Dry-run requests ignore the `Idempotency-Key` header (they persist nothing, so there is nothing to make idempotent).

For the strongest guarantee, also put a stable `client_work_ref` on every work: it makes a re-send an exact upsert (create-or-update) at the database level, so even a retry outside the 24 hour idempotency window cannot create a duplicate. The `Idempotency-Key` is the convenience layer, `client_work_ref` is the durable one.

---

## POST /api/v1/catalogue/works

Upsert one to 500 works into a catalogue. Creates a new catalogue when `catalog_id` is omitted, or appends to an existing one when it is provided.

### Headers

| Header | Required | Description |
|---|---|---|
| `Authorization` | Yes | `Bearer trk_live_...` |
| `Content-Type` | Yes | `application/json` |
| `Idempotency-Key` | Recommended (commit) | Unique per request; makes a commit safe to retry. Ignored in dry-run. |
| `x-client-id` | No | If sent, must equal the key's client, else `403`. |

### Query parameters

| Param | Type | Description |
|---|---|---|
| `dry_run` | boolean | `?dry_run=true` validates and reports without persisting (equivalent to body `"mode": "validate"`). |

### Request body

The body is a strict JSON object. **An unknown or misspelled field returns `400`; it is not silently ignored.** All shares (writer, publisher, master) are expressed on a **0 to 100** scale, so `50` means 50% and `1` means 1%. An out-of-range share is a `400`.

```json
{
  "catalog_name": "Q3 Catalogue Sync",
  "works": [
    {
      "primary_title": "Midnight Drive",
      "client_work_ref": "ACME-0001",
      "recording": {
        "artist_name": "Nova Ray",
        "isrc": "USRC12345678",
        "album_title": "Night Sessions",
        "release_date": "2024-06-15",
        "label": "Acme Records",
        "upc": "012345678901",
        "master_share": 100
      },
      "writers": [
        {
          "writer_first_name": "Alex",
          "writer_last_name": "Stone",
          "writer_ipi": "00123456789",
          "writer_role_code": "CA",
          "writer_pro_affiliation": "ASCAP",
          "writer_ownership_pct": 50,
          "writer_controlled": true
        }
      ],
      "publishers": [
        {
          "publisher_name": "Acme Publishing",
          "publisher_ipi": "00987654321",
          "publisher_collection_share": 25,
          "publisher_pr_share": 50,
          "publisher_pro_affiliation": "ASCAP",
          "publisher_controlled": true,
          "publisher_role": "E",
          "publisher_mlc_number": "800000001",
          "territory": "World",
          "linked_writer_index": 0
        }
      ],
      "iswc": "T-123.456.789-0",
      "mlc_song_code": "A12345",
      "duration": "03:42"
    }
  ]
}
```

#### Envelope (top level)

| Field | Type | Required | Description |
|---|---|---|---|
| `client_id` | string | No | If present, must match the client bound to your key. |
| `catalog_name` | string | No | Names a new catalogue. Default: `API Import YYYY-MM-DD`. |
| `catalog_id` | string | No | Append to an existing catalogue. A new one is created if omitted. `404` if it is not one of yours. |
| `mode` | enum | No | `"validate"` (dry-run) or `"commit"` (default). |
| `works` | array | Yes | 1 to 500 works. |

#### works[]

| Field | Type | Required | Description |
|---|---|---|---|
| `primary_title` | string | Yes | Song title. |
| `client_work_ref` | string | Recommended | Your own stable id for this work. It is the upsert key: re-sending the same ref **updates** that work instead of creating a duplicate, and the response echoes it against our `work_id`. On an update your payload is authoritative for the roster (the `writers` and `publishers` you send **replace** the stored ones), while identifiers you omit (`iswc`, `duration`, `mlc_song_code`) and data we discovered by enrichment are kept. An update always stays in the work's existing catalogue (a `catalog_id` in the request only governs where **new** works are created). |
| `recording` | object | Yes | See below. |
| `writers` | array | No | See below. |
| `publishers` | array | No | See below. |
| `iswc` | string | No | Canonicalised on store. |
| `mlc_song_code` | string | No | MLC song identifier. |
| `duration` | string | No | Canonicalised to `HH:MM:SS` on store. |

#### works[].recording

| Field | Type | Required | Description |
|---|---|---|---|
| `artist_name` | string | Yes | Primary recording artist. |
| `isrc` | string | No | The recommended match anchor. Canonicalised on store. |
| `album_title` | string | No | Album name. |
| `release_date` | string | No | Release date (ISO format recommended). |
| `label` | string | No | Record label. |
| `upc` | string | No | Universal Product Code. |
| `master_share` | number (0-100) | No | Sound-recording ownership (SoundExchange "Percentage Claimed"). |

#### works[].writers[]

| Field | Type | Required | Description |
|---|---|---|---|
| `writer_first_name` | string | Yes | Writer's first name. |
| `writer_last_name` | string | Yes | Writer's last name. |
| `writer_ipi` | string | No | IPI name number (9 to 11 digits). |
| `writer_role_code` | enum | No | One of `CA` (default), `C`, `A`, `AR`, `AD`, `SR`, `SA`, `TR`. |
| `writer_pro_affiliation` | string | No | PRO (e.g. `ASCAP`, `BMI`, `SOCAN`). |
| `writer_ownership_pct` | number (0-100) | No | The writer's share of the **composition**. |
| `writer_controlled` | boolean | No | Whether you control this writer. Default `false`. |

#### works[].publishers[]

| Field | Type | Required | Description |
|---|---|---|---|
| `publisher_name` | string | Yes | Publisher name. |
| `publisher_ipi` | string | No | Publisher IPI name number. |
| `publisher_collection_share` | number (0-100) | No | **Mechanical** collection share (the MLC value). |
| `publisher_pr_share` | number (0-100) | No | **Performance** collection share. |
| `publisher_pro_affiliation` | string | No | Publisher's PRO. |
| `publisher_controlled` | boolean | No | Whether this is a controlled publisher. Default `false`. |
| `publisher_role` | enum | No | `E` (original), `AM` (admin), `SE` (sub-publisher). |
| `publisher_mlc_number` | string | No | MLC publisher number. |
| `territory` | string | No | Territory. Default `"World"`. |
| `linked_writer_index` | integer | No | 0-based index into **this work's** `writers` array: which writer this publisher collects for. |
| `linked_writer_ipi` | string | No | Alternative to the index: the IPI of the writer this publisher collects for. |

> **Link your publishers to their writers.** Provide `linked_writer_index` (or `linked_writer_ipi`) so publisher attribution is correct in your exports. If you omit it and the work has exactly one writer, the publisher is auto-linked to that writer. With two or more writers and no link, the publisher is left **unlinked**, which weakens the export. We strongly recommend always setting the link.
>
> A **provided** link must resolve, or the request is rejected with a **`400`** (this is a structural error, returned even in dry-run/validate mode, not a per-work gap): `linked_writer_index` must be within range for the work's `writers`, `linked_writer_ipi` must match one writer's IPI on the work, and if you send both they must point to the same writer.

---

## Share semantics

> These are **four different numbers**. Never copy one into another, and never default one from another.
>
> | Value | Field | Meaning |
> |---|---|---|
> | Composition ownership | `writer_ownership_pct` | The writer's share of the underlying song. |
> | Mechanical collection | `publisher_collection_share` | What the publisher collects on mechanicals (the MLC value). |
> | Performance collection | `publisher_pr_share` | What the publisher collects on performance. |
> | Sound-recording ownership | `master_share` | Ownership of the master (the recording), not the song. |
>
> **Worked example.** A writer owns 50% of the composition, while their publisher collects 25% of the mechanicals. Send `writer_ownership_pct: 50` on the writer and `publisher_collection_share: 25` on the publisher. They are different figures and both are correct; do not set the publisher share to 50 just because the writer owns 50.

All shares use the 0 to 100 scale. `50` is 50%, `1` is 1%. Out-of-range values return `400`.

---

## Your data will be transformed

TakeRecord normalises what you send, and it reports every change back in the response so nothing is silently mutated. Expect the following:

- **Shares are divided by 100 on store.** You send `50`; it is stored as the fraction `0.5`. This is lossless and only affects how the value appears in downstream systems.
- **Identifiers are canonicalised.** ISRC, ISWC, and `duration` are normalised to their canonical forms (for example `duration` becomes `HH:MM:SS`). The `canonicalised` count in the response tells you how many fields changed.
- **Junk identifiers are blanked to a gap.** If an identifier cell holds prose or obvious junk rather than a real code, it is cleared and the field is reported as a gap. The `cleansed_identifiers` object counts these per identifier type (for example `{ "ISRC": 3 }`).
- **Request-prose is stripped.** Free-text instructions that leaked into structured fields are removed. The `prose_stripped` count reports how many works were affected.
- **Rosters can be split and de-duplicated.** A single cell that lists several writers is split into separate writers, and duplicates are merged. Because of this the stored writer count can differ from what you sent; the `roster_adjusted` count reports how many works had their roster changed.

The response fields `cleansed_identifiers`, `roster_adjusted`, `prose_stripped`, and `canonicalised` report exactly what the pipeline changed.

---

## Dry-run / validate mode

To validate without persisting anything, use either:

- the query param `?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` report you would get from a real commit.

Nothing is stored, no catalogue is created, and `Idempotency-Key` is ignored. Use it to preview data quality before committing.

---

## Sending a full catalogue (~5,000 songs)

The endpoint accepts up to 500 works per request. To load a large catalogue, page it in chunks and let each chunk be independently retryable.

**Rules of thumb:**

- Split into chunks of **500 works** each.
- Send **one `Idempotency-Key` per chunk** (a fresh UUID per chunk). A retried chunk then replays rather than double-writing.
- Put a **stable `client_work_ref` on every work** so a re-send upserts (merges) instead of creating duplicates.
- On `429` or any `5xx`, **retry the same chunk** (same body, same key) with **exponential backoff**.
- Optionally run each chunk as a **dry-run first** to preview gaps, then commit.

**Pseudo-code:**

```
for chunk in chunks(works, size=500):
    key = uuid()
    backoff = 1s
    while true:
        res = POST /api/v1/catalogue/works
              Authorization: Bearer trk_live_...
              Idempotency-Key: key
              body: { catalog_id, works: chunk }
        if res.status in (200, 201):
            break                       # success (idempotent replay counts as success)
        if res.status in (429, 500, 502, 503, 504):
            sleep(backoff); backoff = min(backoff * 2, 60s); continue
        raise res                       # 4xx other than 429: fix the request, do not retry
```

Create the catalogue once on the first chunk (omit `catalog_id`), then pass the returned `catalog_id` on every subsequent chunk so all chunks land in the same catalogue.

**Curl: dry-run a single chunk, then commit it.**

```bash
# 1) Dry-run: validate this chunk, persist nothing (returns 200).
curl -X POST "https://ingest.takerecord.com/api/v1/catalogue/works?dry_run=true" \
  -H "Authorization: Bearer $TRK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "catalog_name": "Q3 Catalogue Sync",
    "works": [
      {
        "primary_title": "Midnight Drive",
        "client_work_ref": "ACME-0001",
        "recording": { "artist_name": "Nova Ray", "isrc": "USRC12345678", "master_share": 100 },
        "writers": [
          { "writer_first_name": "Alex", "writer_last_name": "Stone", "writer_ipi": "00123456789", "writer_ownership_pct": 50 }
        ],
        "publishers": [
          { "publisher_name": "Acme Publishing", "publisher_collection_share": 25, "linked_writer_index": 0 }
        ]
      }
    ]
  }'

# 2) Commit: same chunk, live, with an Idempotency-Key so a retry is safe (returns 201).
curl -X POST "https://ingest.takerecord.com/api/v1/catalogue/works" \
  -H "Authorization: Bearer $TRK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5f2c9a1e-8b3d-4c7a-9e21-0a1b2c3d4e5f" \
  -d '{
    "catalog_name": "Q3 Catalogue Sync",
    "works": [
      {
        "primary_title": "Midnight Drive",
        "client_work_ref": "ACME-0001",
        "recording": { "artist_name": "Nova Ray", "isrc": "USRC12345678", "master_share": 100 },
        "writers": [
          { "writer_first_name": "Alex", "writer_last_name": "Stone", "writer_ipi": "00123456789", "writer_ownership_pct": 50 }
        ],
        "publishers": [
          { "publisher_name": "Acme Publishing", "publisher_collection_share": 25, "linked_writer_index": 0 }
        ]
      }
    ]
  }'
```

---

## Response

A commit returns `201`; a dry-run returns `200`. The body is the same shape either way:

```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 a different party. Check this is the right writer."
          }
        ]
      }
    ]
  },
  "cleansed_identifiers": { "ISRC": 0 },
  "roster_adjusted": 0,
  "prose_stripped": 0,
  "canonicalised": 1
}
```

### Fields

| Field | Type | Description |
|---|---|---|
| `catalog_id` | string | The catalogue the works landed in (or would land in, for a dry-run). |
| `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 skipped (a title already present when appending without a `client_work_ref`). |
| `work_ids` | string[] | Ids of **created** works, format `REC-YYYY-NNNNN` (for example `REC-2026-00001`). Empty in dry-run. |
| `results` | array | Per-work echo: `{ client_work_ref, work_id, action }` where `action` is `"created"`, `"updated"`, or `"skipped"`. Reconcile your id to ours here. In dry-run, `work_id` is `null`. |
| `gaps` | array | Presence report: `{ work_index, title, missing_fields[] }`. |
| `validation` | object | Correctness report (see below). |
| `cleansed_identifiers` | object | Optional. Counts of identifier cells blanked to a gap, keyed by type (for example `{ "ISRC": 3 }`). |
| `roster_adjusted` | integer | Works whose writer roster was split/de-duplicated. |
| `prose_stripped` | integer | Works that had request-prose removed from structured fields. |
| `canonicalised` | integer | Identifier/duration fields normalised to canonical form. |

### gaps vs validation

These are two complementary reports, and **neither blocks the write**. Works are always stored as delivered; both reports surface things to fix.

- **`gaps` is a presence report.** It lists what is *missing*. Possible `missing_fields`: `isrc`, `iswc`, `writers`, `writer_ipi`, `writer_ownership_pct`, `publishers`, `publisher_collection_share`.
- **`validation` is a correctness report.** It flags data that is *present but wrong*. `validation.issues` lists only flagged works; each `fields[]` entry is `{ field_name, reason, code, suggested_action }`.

Validation `code` values:

| `code` | Meaning |
|---|---|
| `ipi_format` | Writer/publisher IPI is not a 9 to 11 digit number. |
| `ipi_unregistered` | Writer IPI is in no registry (a fake or typo). |
| `ipi_wrong_party` | Writer IPI is registered, but to a different party (a valid-but-wrong-person IPI). |
| `iswc_format` | ISWC present but not a valid ISWC. |
| `isrc_format` | ISRC present but not a valid ISRC. |
| `writer_share_over` | Writer ownership shares exceed 100%. |
| `collection_share_over` | Publisher collection shares exceed 100%. |

`attestation_ran` is `false` when the registry check was skipped (for example the registry was unavailable). Format and share checks still run; only the registry checks (`ipi_unregistered`, `ipi_wrong_party`) are skipped.

---

## Errors

Error bodies have the shape `{ "error": "message", "code"?: "..." }`. Every response, including errors, carries an `x-request-id` header.

| Status | Meaning |
|---|---|
| 400 | Validation failed: an unknown/misspelled field, a share out of the 0 to 100 range, or a body `client_id` that does not match your key. |
| 401 | Missing, invalid, expired, or revoked API key. |
| 403 | Not a per-client key (a generic/shared key), missing scope, or an `x-client-id` header that does not match your key's client. |
| 404 | `catalog_id` is not one of your catalogues. |
| 409 | An `Idempotency-Key` is still being processed. Retry shortly. |
| 422 | An `Idempotency-Key` was reused with a different request body. |
| 429 | Rate limit exceeded (60/min). Back off and retry. |
| 500 | Unexpected server error. Retry with backoff; quote the `x-request-id`. |

---

## Versioning

This surface is versioned in the path (`/api/v1/`). Changes are **additive**: we may add new optional request fields and new response fields without notice, so parse the response leniently and ignore fields you do not recognise. Any **breaking** change ships under a new version path (for example `/api/v2/`); `/api/v1/` will not change in a way that breaks existing integrations.
