Catalogue Ingest API
Push your catalogue of musical works into TakeRecord over HTTPS. Authenticate with a per-client key, upsert works, and get validation and gaps back in the response.
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:
- Open Settings.
- Go to the API keys section.
- Click Manage API keys.
- Click Create key and give it a name.
- 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-idheader is required. - If you do send
x-client-id(or a bodyclient_id), it must equal the client bound to the key. A mismatch is rejected (403for the header,400for 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 with403.
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-idheader. 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-Keyper 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.
{
"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. |
Share semantics#
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 fraction0.5. This is lossless and only affects how the value appears in downstream systems. - Identifiers are canonicalised. ISRC, ISWC, and
durationare normalised to their canonical forms (for exampledurationbecomesHH:MM:SS). Thecanonicalisedcount 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_identifiersobject 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_strippedcount 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_adjustedcount 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_idset tonull, - the same
gapsandvalidationreport 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-Keyper chunk (a fresh UUID per chunk). A retried chunk then replays rather than double-writing. - Put a stable
client_work_refon every work so a re-send upserts (merges) instead of creating duplicates. - On
429or any5xx, 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.
# 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:
{
"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.
gapsis a presence report. It lists what is missing. Possiblemissing_fields:isrc,iswc,writers,writer_ipi,writer_ownership_pct,publishers,publisher_collection_share.validationis a correctness report. It flags data that is present but wrong.validation.issueslists only flagged works; eachfields[]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.