# Tangle API: instructions for agents

> You are helping a developer integrate their app or service with **Tangle**, so that any Tangle user can connect their music collection and the app can read and update it. (You may also be helping someone script their own collection; that is the simpler case, covered below.)
> The Tangle API implements **Open Music Collection (OMC) Access** at `https://api.tangle.music/omc/v1`. OMC is an open standard: its specification defines the records and the protocol, and this file says only what is Tangle's own. Read this whole file, then the parts of the specification it links to, before you write code.

## What Tangle is

Tangle is a music app for DJs and collectors. It pulls a person's music from Spotify, Tidal, SoundCloud, YouTube Music, Apple Music, Bandcamp, Discogs, Rekordbox and local folders into one collection, where one song can have copies on several platforms. People organise it into **playlists** (ordered lists) and **tags** (unordered lists), on Tangle itself or on a platform.

In OMC's words, Tangle's server is an **OMC home**: it keeps each person's collection (the copy Tangle keeps in the cloud for their **Backup and restore**) and lets apps connect to it. Each user chooses **Connect your Tangle collection** in your app, sees what it asks for, and chooses **Allow**. Every change your app makes is a change the user could have made in Tangle, with the same effects. An app never sees a collection whose owner hasn't connected it.

Two words this file keeps apart: the **developer** is the person you are working with, who builds the app; a **user** is any Tangle user who connects it.

## Open Music Collection

Open Music Collection (OMC) is an open standard for a person's music collection: the songs and albums they keep (**items**), where each one lives (**copies** on **sources**), and the **lists** they organise them into. Tangle looks after the specification; anyone can implement it.

- **Core 1.0** (the records, the OMC file format, extensions and keeping unknown data): https://www.tangle.music/omc/spec/
- **Access** (the protocol apps use to read and change a collection at its home, with every endpoint): https://www.tangle.music/omc/modules/access/
- OMC's own instructions for agents, with the rules every OMC app follows: https://www.tangle.music/omc/agents.md

Which document answers what: what a record or field means, and how an endpoint behaves, is in the OMC specification. What Tangle supports of it, and what Tangle adds, is in *What Tangle supports* below. An app written against Access works with any OMC home, Tangle included.

## Your first five minutes

1. **Ask what the developer wants to build.** Almost always it is an app or service other people use: go on with step 2. If they only want to work on *their own* collection (a script, a one-off), skip to *Scripting your own collection* below.
2. **Give the app an identity.** Either way works with Tangle; the first works with every OMC home:
   - **By address** (no registration): publish a small JSON document at an `https://` URL on the app's own site and use that URL as the `client_id`. See *Apps known by their address* below. Users see the app as not reviewed by Tangle, with the site it comes from.
   - **Registered** (Tangle only), which you can't do yourself: the developer signs in at https://www.tangle.music/developers/dashboard/, turns on **Developer mode**, opens **Apps → New app**, and enters the app's name, its redirect URI(s) and its type (**confidential** for a server that can keep a secret, **public** for browser, mobile, desktop and CLI apps). Ask them for the **client id**, and have them put the client secret (confidential apps) in the app's secret store or an environment variable, never in code or chat.
   - Either way, the app's name can't contain "Tangle", so users never mistake it for Tangle's own.
3. **Build Connect your Tangle collection**: a button that starts the OAuth flow (see *Authentication*), a callback route that exchanges the code, and storage for each connected user's tokens. Store the user's id from `GET /omc/v1/me` (`user.id`) next to your own user record.
4. **Handle users without Backup and restore.** After connecting, call `GET /omc/v1/me`. If `collection_available` is `false`, tell the user in your app that Tangle needs Backup and restore turned on (Tangle → Settings → Library) before their collection is available. Every other call answers `403 backup_and_restore_required` until they do.
5. **Handle disconnects.** Users can disconnect your app in Tangle at any time. Then their tokens answer `401 invalid_token` and refreshing fails with `invalid_grant`; with Tangle's webhooks you also get `authorization.revoked`. Mark the user disconnected and offer Connect your Tangle collection again.
6. **Test with a real account.** The developer can connect their own Tangle collection to the app while developing.

### Scripting your own collection

For a script that works only on the developer's own collection, a **personal access token** (Tangle only) is simpler than OAuth: they create one at https://www.tangle.music/developers/dashboard/ (**Tokens**), choose its scopes, and put it in an environment variable rather than pasting it into the conversation: `export TANGLE_API_TOKEN=tng_pat_…`. Check it with:

```bash
curl -s https://api.tangle.music/omc/v1/me -H "Authorization: Bearer $TANGLE_API_TOKEN"
```

Personal access tokens only ever reach their creator's own collection. Never use one for other users.

## Rules for agents

- **Never print, log, commit or echo a token or client secret.** Read them from the environment or the app's secret store. Don't put them in URLs or in client-side code.
- **Keep each user's tokens apart.** Store tokens per connected user, encrypted at rest, and only ever use a user's token for that user's requests.
- **Ask only for the scopes the app uses.** Users see them on the consent page. An app that only brings music in needs nothing but `library:add`.
- **Confirm destructive changes with the user.** In the app, ask before deleting a list, removing more than a handful of items, or changing lists on a platform (Spotify, Tidal, …): those changes reach the platform. Never do them in the background.
- **Ids are local.** Ids (`trk_`, `rel_`, `lst_`, `src_`) mean something only in the collection they came from: never build, parse or compare them across users. To find a user's item for something you know from elsewhere, look it up by content (`?source=&ref=`, `?isrc=`, `?upc=`, `?musicbrainz=`), as the specification says: https://www.tangle.music/omc/spec/#the-model
- **Keep what you don't understand.** Send back whole records you read, unknown fields included: https://www.tangle.music/omc/spec/#extensions-and-unknown-data
- **Expect fields to be missing.** Tangle sends only what it knows: a field it has no data for is absent, never empty or guessed.
- **Ask the list, every time.** Each list carries `can`: what your app may do with it now. Don't store it; `GET /omc/v1/lists/can` gives it for every list at once.
- **Address ordered lists by position and `version`.** A playlist can hold a song twice, so its entries are positions in the list as you read it, and you send the `version` you read. On `409 version_conflict`, read the list again and retry.
- **Retrying is safe.** Adding an entry that is already in an unordered list, or removing one that isn't, changes nothing and is reported in `unchanged`.
- **Write only your own namespace.** Keep your app's data in `ext` under a reverse-domain name you own (`com.example.app`). Another app's namespace answers `403 namespace_not_yours`.
- **Respect rate limits.** On `429`, wait the number of seconds in the `Retry-After` header, then retry. Prefer batches (up to 100 items per request) to many single calls, and the event stream to polling.
- **Use only `/omc/v1/*` and `/oauth/*`.** Everything else on Tangle's servers is internal and changes without notice.
- **Stay portable.** Build on what OMC Access defines, so the app works with any OMC home. Features marked *Tangle only* (see *Tangle-only features*) are optional extras: never make them the only way your app works.
- **Report errors with their `request_id`** so the developer can quote it to Tangle support.

## What Tangle supports

Tangle's facts about its OMC implementation, in one place. `GET /omc/v1/me` gives the modules, scopes and limits at run time.

### Modules and base URL

Core 1.0 and Access 0.2. `/me` lists them as `"modules": { "core": "1.0", "org.openmusiccollection.access": "0.2" }`.

The base URL is `https://api.tangle.music/omc/v1`. Requests and responses are JSON; `GET /omc/v1/collection` is NDJSON.

`GET /omc/v1/me` also gives:

- `user.id`, and `user.name`: the person's Tangle username, a name to show;
- `capabilities`, following the token's scopes;
- `limits`: `data_bytes` (32 KB: your app's namespace on one record, its copies included), `module_data_bytes` (1 MB: one module's data on one record), `entries_per_request`, `ids_per_request`, `import_records`, `additions_per_hour`;
- `client_id_metadata: true`: apps may connect by their address (see *Authentication*);
- `collection_available`: `false` until the user turns on Backup and restore.

### Scopes

Users see each scope on the consent page in these words:

| Scope | Consent line | In Tangle |
|---|---|---|
| `library:read` | See your collection | Items, sources and module records |
| `lists:read` | See your lists | Playlists and tags, with their entries |
| `lists:write` | Change your lists | Create, rename and delete lists; add, remove and move entries, and their notes; your app's data on lists. Includes `lists:read` |
| `library:write` | Add to and change your collection | Your app's data on items and sources, the person's `rating`, module records, and importing. Includes `library:read` and `library:add` |
| `library:add` | Add music to your collection | Add items, create lists and fill the lists your app made, without reading anything else: https://www.tangle.music/omc/modules/access/#apps-that-only-add |
| `listens:write` | Tell Tangle what you play | Send listens (see *Listens*) |
| `listens:read` | See what you've played | Read listens. Includes `listens:write` |

### What Tangle exports

Each record is OMC Core (https://www.tangle.music/omc/spec/). What Tangle fills in:

- **Items:** recordings (Tangle's tracks) and releases (the albums their copies come from), each with its copies on the user's sources. Releases carry `label`, and from Discogs `catalog_number` and `country`; Discogs copies carry `format`, `format_details` and `condition`.
- **`ids.isrc`:** the ISRC the platforms give (Apple Music, Spotify imports, saved search results), the one most copies agree on.
- **`note`:** the comment kept in Rekordbox, iTunes or the file (`provenance.note.origin` is `embedded`), or the note an app wrote, which is the person's and wins.
- **`key`:** in ID3 `TKEY` form (`Am`, `F#`, `Dbm`), read from TKEY, Rekordbox, Camelot or Open Key. A value that isn't a key is left out.
- **`rating`:** 1 to 5. The person's own when an app wrote one (`provenance.rating.origin` `user`), otherwise the platforms' star rating (origin `platform`).
- **`play_count`** and **`last_played_at`:** counted from listens (see *Listens*). Read only.
- **Folders:** Rekordbox playlist folders and My Tag groups are lists with `folder: true` and no entries; lists name their folder in `parent`, with the platform's `position`.
- **Colours:** each Rekordbox colour in use is an unordered list with `color`, in a "Colours" folder on that source.
- **Smart lists:** filters saved in Tangle are lists with an OMC `filter` when every rule is on a core field; their entries are evaluated when you read them. Other saved filters aren't exported.
- **List entries:** on a list that lives on a platform, each entry carries `ref`, the platform's id of the copy it is there. An entry may have a `note`.

Folders, colours and smart lists are read only for apps (`can` all `false`; writes answer `422 list_not_writable`), though your app may keep its own data on them.

Not exported, because Tangle doesn't have the data yet: `composers`, `album_artists`, `release_type`, a copy's `file` (size, format, hashes), cue points and grids, Rekordbox history and Intelligent Playlists. These fields are absent, not empty.

### Reading

Everything in Access: https://www.tangle.music/omc/modules/access/#reading

- Lookups by content: `GET /omc/v1/items?source=…&ref=…` (`404 source_not_found` when the source isn't the user's), `?isrc=…`, `?upc=…`, `?musicbrainz=…`.
- `GET /omc/v1/collection` sends the change cursor in the `OMC-Cursor` response header. It counts as 60 requests against the rate limit, and one runs per user at a time.
- `GET /omc/v1/changes` and `GET /omc/v1/events`, see *Keeping up* below.
- Every list in a response carries `can` (`add`, `remove`, `reorder`, `rename`, `delete`): what this app may do with the list now, from the platform's rules and the token's scopes. `reorder` means placing or moving entries to a position rather than only adding at the end. A Bandcamp collection is read only; Apple Music playlists only take additions, at the end. `GET /omc/v1/lists/can` answers `{ "lists": { "lst_…": { "add": true, … } } }` for every list your app can see, so you can show it right after reading the collection file, which has no `can`. When a list's `can` changes (the user connected or disconnected its platform), `/changes` reports the list.
- Every list in a response also carries `version`, which changes whenever its entries do; `GET /omc/v1/lists/{id}` sends it in `ETag` too. Neither `can` nor `version` is ever in the collection file.

### Writing

As in Access (https://www.tangle.music/omc/modules/access/#writing):

- `PATCH` items, sources and lists: your app's data (`ext` in your namespace, `ids`, `provenance`, fields Tangle doesn't know, data on copies) is kept and given back unchanged. Tangle's own fields must be sent unchanged or left out (`422 field_not_writable`): https://www.tangle.music/omc/modules/access/#how-patch-works
- `rating` (`library:write`) and `note` are the exceptions: what your app writes there is the person's own value. `ids.isrc`, `play_count` and `last_played_at` are Tangle's.
- Lists: create (on Tangle, or on a platform with `source`; a source's `ext["music.tangle"].creatable_list_kinds` says what it can hold), rename, delete, and change entries (below). A new list may come with its entries, notes included. Apps can't create folders, colours or smart lists yet.
- Module records (such as `org.openmusiccollection.feeds/follow`): `PUT` and `DELETE`.
- Every successful write answers with an `OMC-Cursor` header: the change-feed position just past the write. Apps with only `library:add` get none.

#### Entries

Ordered lists (playlists) are addressed by **position**: the 0-based index in `entries` at the list's `version`. Send the `version` you read with every request that names a position; if the list changed since, Tangle answers `409 version_conflict` with the current version in `ETag`. Read the list again and retry. Unordered lists (tags) are addressed by item id, without a version. https://www.tangle.music/omc/modules/access/#entries-by-position

| Request | Body |
|---|---|
| `POST /omc/v1/lists/{id}/entries` | `{ "items": ["trk_…"] }`, adding at the end; on an ordered list optionally `"before": 3, "version": "…"` |
| `DELETE /omc/v1/lists/{id}/entries` | Ordered: `{ "positions": [3, 7], "version": "…" }`. Unordered: `{ "items": ["trk_…"] }` |
| `POST /omc/v1/lists/{id}/entries/move` | `{ "from": 3, "before": 0, "version": "…" }`: one move, which reaches the platform as one change |
| `PATCH /omc/v1/lists/{id}/entries` | Ordered: `{ "position": 3, "version": "…", "note": "…" }`. Unordered: `{ "item": "trk_…", "note": "…" }`. `null` removes the note |

A playlist takes a song it already has only where its platform allows repeats (Spotify, Tidal and Tangle's own playlists); elsewhere the addition is reported in `unchanged`. Removing one occurrence of a repeated song leaves the others.

#### Adding items

`POST /omc/v1/items` takes recordings and releases (https://www.tangle.music/omc/modules/access/#adding-an-item):

- When the user already has the item (matched by a copy, an identifier, or for a release its label and catalogue number), the answer is `"result": "existing"` with its `id`.
- Otherwise Tangle holds it in the user's **Captured** until they keep it: `202`, `"result": "held"`, no `id` yet, and `new` says whether it was already waiting. It appears in `/changes` once kept. Tangle holds every new item, so `?hold=true` is accepted and changes nothing.
- When the user keeps an item your app added (here or by importing), it gets a `via`: the one you sent, else the shop its link was on, else your app's name and homepage (for an app known by its address, its document's `client_uri`). An item the user already had keeps the `via` it had. Items added with a personal token get none.
- `?list=lst_…` also puts an existing item in that list; the answer's `list` is `added`, `already` or `not_added` (a held item, for now).
- An item needs a copy on a registered platform (its `url`, or just its `ref`), a `via` with a `url`, or identifiers (`422 missing_url` otherwise). A link to any other site, such as a record shop's page, isn't a copy: send it in `via` (`{ "name": "Hard Wax", "url": "…" }`).

#### Importing

`POST /omc/v1/import` combines a piece of a collection file into the collection, as Access describes, with the same answer (`matched`, `created`, `suggested`, `held`, `left`, `ids`): https://www.tangle.music/omc/modules/access/#importing

- Scope `library:write`; lists in the piece also need `lists:write`. `?dry_run=true` reports what would happen without changing anything; `?conflicts=home` (the default) or `?conflicts=import` settles values both sides have.
- Tangle recognises records it exported itself by their id, so importing a Tangle export again updates instead of duplicating.
- What Tangle can't create from a file is held in Captured (at most 100 new items per import), or is `left` with a reason: sources must be connected in Tangle itself, and local folders match only by id. A `rating` comes along only when the file marks it as the person's.
- Up to 20,000 records per request, and 20 imports an hour.

#### Namespaces and budgets

Your app's `ext` namespace is yours: an app known by its address owns the reverse-domain names of its host (`app.example.com` owns `com.example.app`); a registered app owns a namespace by writing it first. Another app writing it gets `403 namespace_not_yours`. Module namespaces (`org.openmusiccollection.*`) are shared by every app with the scope, and the user's own changes (in Tangle, or with a personal token) are never limited. Sending back a record you read, other apps' data unchanged, is always fine.

Your app's namespace may hold up to 32 KB on a record (`data_bytes`) and each module's data up to 1 MB (`module_data_bytes`); more answers `413 data_too_large`.

### Listens

Tangle keeps what the user plays as **listens**, in ListenBrainz's format, so a player that already scrobbles needs no new code (https://www.tangle.music/omc/modules/access/#listens):

- `POST /omc/v1/listens` (scope `listens:write`) takes ListenBrainz's submit-listens body: `single`, or `import` with up to 1,000 listens; `playing_now` is accepted and not kept. A player that talks to a ListenBrainz server can also point at `https://api.tangle.music/omc/v1/listenbrainz/1/submit-listens` and send its token as `Authorization: Token …`.
- Each listen is matched to a recording by ISRC, MusicBrainz id, Spotify id or a copy's link, then by title and artist when exactly one matches. That item's `play_count` and `last_played_at` follow, and `/changes` reports it. The same listen sent twice is kept once.
- `GET /omc/v1/listens` (`listens:read`) answers ListenBrainz's shape, newest first (`min_ts`, `max_ts`, `count`), each listen with the `item` it matched.
- Listens are never in the collection file. Tangle's own player doesn't send listens yet, so counts come from apps.

### Not yet

- **Views:** every connection sees the whole collection its scopes allow; only an add-only app is limited, to the lists it made (`403 out_of_view` elsewhere).
- **`via` on releases held as links:** they don't get a `via` yet.
- **Held items and `?list=`:** a held item doesn't reach the list when the user keeps it.
- **Writing folders, colours and smart lists**, and Tangle's apps showing the rating your app wrote.

### Tangle's own data

Tangle's namespace is `music.tangle`. In `ext["music.tangle"]` Tangle keeps only what it alone needs to restore its own state:

- on items and copies: `hidden` (what the user hid in Tangle);
- on lists: `kind` (Tangle's own kinds, such as `rekordbox.color` and `saved_filter`), `track_count` (only in list summaries, which have no entries), `linked_list_ids` (lists the user combined: adding an item to one adds it to the others where that platform has it) and `hidden`;
- on entries: `status: "unavailable"` (greyed out on the platform);
- on sources: `platform`, `status` and `creatable_list_kinds`.

Read it, but don't write it: Tangle ignores what you send there, so sending back a record you read is always safe.

Tangle's apps call recordings *tracks*, ordered lists *playlists* and unordered lists *tags*.

### Tangle-only features

OMC separates what every home does from what one home offers. Tangle follows the specification's own table (https://www.tangle.music/omc/spec/where/): what an app needs is in OMC; these are Tangle's product, outside OMC, and no OMC app should need them:

| Tangle only | What to use for any OMC home |
|---|---|
| Registered apps and the developer dashboard | An app known by its address |
| Personal access tokens | OAuth, with a loopback redirect for scripts |
| Webhooks | `/changes` and `/events` |
| Captured (where held items wait) | `"result": "held"`, and `/changes` once kept |
| The older `/v1` API | `/omc/v1` |

Home policy, which another home may set differently, is announced in `/me`: limits and budgets, holding every new item, who owns a namespace, rate limits.

### How a change travels

When your app adds an item to a user's list:

1. It is saved at once in the user's Backup and restore copy. Your response already shows it.
2. The user's open Tangle apps receive it within about a second.
3. If the list lives on a platform (Spotify, Tidal, YouTube Music, SoundCloud, Rekordbox, …), Tangle shows it as a change waiting to sync, and sends it to the platform on the next sync, or within about half a minute when **live sync** is on and a Tangle app is open. Records show the list as it will be; what hasn't reached the platform yet is Tangle's own state and isn't in them.

Show users this when it matters: *"Added. It's in Tangle now; it reaches Spotify on your next Tangle sync (or within a minute if live sync is on)."*

An item that isn't on the list's platform yet (say a Bandcamp purchase added to a Spotify playlist) is still added in Tangle; Tangle's matching finds the platform's copy before it syncs.

## Authentication

Send every request with `Authorization: Bearer <token>` over HTTPS. Two kinds of token work:

- **OAuth access tokens** (`tng_at_…`): issued to an app for one connected user by the flow below, valid one hour, renewed with a refresh token (`tng_rt_…`). This is how apps reach their users' collections.
- **Personal access tokens** (`tng_pat_…`, Tangle only): created by a developer at https://www.tangle.music/developers/dashboard/ for their own collection only, valid 7 to 365 days, revocable there at any time.

### OAuth 2.1: Connect your Tangle collection

Access's consent rules apply (https://www.tangle.music/omc/modules/access/#consent). The app is known by its `client_id`: its own address (below), or one Tangle gave it when it was registered at https://www.tangle.music/developers/dashboard/ (**Apps → New app**, Tangle only) with a name, the redirect URIs it uses, and its type:

- **Public** (single-page apps, mobile and desktop apps, CLIs, agents): no client secret; PKCE protects the flow.
- **Confidential** (a server you control): also gets a client secret, sent at the token endpoint.

Redirect URIs must match exactly. Allowed: `https://` URLs, `http://127.0.0.1`, `http://[::1]` or `http://localhost` (any port, for CLIs that listen locally), and reverse-domain schemes for native apps (`com.example.app:/callback`). Discovery metadata: https://api.tangle.music/.well-known/oauth-authorization-server.

**1. Send the user to the consent page** with a fresh PKCE pair (S256 only) and a random `state`:

```
https://www.tangle.music/oauth/authorize?response_type=code
  &client_id=tng_ci_…
  &redirect_uri=https%3A%2F%2Fyour.app%2Fcallback
  &scope=library%3Aread%20lists%3Aread%20lists%3Awrite
  &state=<random>
  &code_challenge=<BASE64URL(SHA256(code_verifier))>
  &code_challenge_method=S256
```

With a `client_id` that is an address, put that URL in `client_id`, encoded like any parameter.

**2. Tangle redirects back** to your `redirect_uri` with `code`, your `state` and `iss` (check that `state` is yours and `iss` is `https://api.tangle.music`), or with `error=access_denied` if the user said no. Codes expire after 5 minutes and work once.

**3. Exchange the code** (form-encoded; JSON is accepted too):

```bash
curl -s https://api.tangle.music/oauth/token \
  -d grant_type=authorization_code \
  -d code=tng_ac_… \
  -d redirect_uri=https://your.app/callback \
  -d client_id=tng_ci_… \
  -d code_verifier=<the verifier>
# Confidential apps also send their secret: -u tng_ci_…:tng_cs_…  (or -d client_secret=…)
```

```json
{ "access_token": "tng_at_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "tng_rt_…", "scope": "library:read lists:read lists:write" }
```

**4. Refresh** before the hour is up with `grant_type=refresh_token&refresh_token=…&client_id=…`. Every refresh returns a **new** refresh token; store it atomically and use only the newest. Presenting a refresh token twice disconnects that user from your app: Tangle treats it as stolen.

Approving the same scopes again skips the consent screen only for `https://` redirect URIs. Loopback and custom-scheme redirects always ask, because another program on the device could claim them.

**5. Disconnect a user** from your side with `POST https://api.tangle.music/oauth/revoke` (`token=…&client_id=…`). Users can also disconnect your app in Tangle at any time (it is listed under **connected apps**, with **Disconnect**); your tokens then answer `401 invalid_token`.

### Apps known by their address

An app can connect without registering: its `client_id` is an `https://` URL with a path on its own site, serving an OAuth Client ID Metadata Document (https://www.tangle.music/omc/modules/access/#who-the-app-is). Any OMC home that supports it (`/me` says `client_id_metadata: true`) accepts the same app.

```json
{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "Example Digger",
  "client_uri": "https://app.example.com/",
  "redirect_uris": ["https://app.example.com/callback"],
  "token_endpoint_auth_method": "none"
}
```

- `client_id` must be the document's own URL, and `token_endpoint_auth_method` `none`: such an app is public, with PKCE and no secret.
- Tangle fetches the document when a user is asked to consent (public addresses only, no redirects, up to 64 KB) and again when its copy is over an hour old. It redirects only to the document's `redirect_uris`.
- The consent page shows the app's name with the host it came from, marked as not reviewed by Tangle.
- The app owns the reverse-domain namespaces of its host: `app.example.com` writes `com.example.app`.
- `client_name` can't contain "Tangle".

## Keeping up

An app downloads `GET /omc/v1/collection` once, keeps the `OMC-Cursor` it got, and follows `GET /omc/v1/changes?cursor=…` from there, re-reading what changed: https://www.tangle.music/omc/modules/access/#changes. Tangle's feed reports every change, whoever made it (your app, the user in a Tangle app, another app, a platform sync). Each change carries `by`, the `client_id` of the app whose write it was, so you can skip your own; it is absent when the user, a Tangle app or a sync made it. Adding or removing an entry is a change to the list, not to the item.

A list you create on a platform gets a new id when Tangle's sync creates it there: the change feed then reports the old `lst_` id as deleted and the new one as changed.

### Event stream (any token)

`GET /omc/v1/events` is a Server-Sent Events stream: `ready` with the current cursor, then `changed` with the new cursor whenever the collection changes, and a comment every 15 seconds to keep the connection open. After an hour it sends `reconnect` and closes; reconnect with the `Last-Event-ID` header and you get a `changed` at once if you missed anything. At most 10 open streams per user. EventSource can't send an `Authorization` header, so read it with `fetch`:

```ts
const res = await fetch("https://api.tangle.music/omc/v1/events", { headers: { Authorization: `Bearer ${process.env.TANGLE_API_TOKEN}` } });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break; // reconnect
  buffer += value;
  let end;
  while ((end = buffer.indexOf("\n\n")) >= 0) {
    const block = buffer.slice(0, end);
    buffer = buffer.slice(end + 2);
    if (/^event: changed$/m.test(block)) await catchUp(); // GET /omc/v1/changes from your stored cursor
  }
}
```

### Webhooks (Tangle only, registered apps)

Webhooks are Tangle's own feature, not part of OMC: an app that must work with any OMC home follows `/changes` and `/events` instead. Give your app a webhook URL in the dashboard (public `https://` only). Tangle then POSTs small JSON events to it:

- `collection.changed`: a user who connected your app changed their collection. `data.cursor` is the newest position; call `/omc/v1/changes` with your stored cursor and that user's token. Several changes in quick succession arrive as one event.
- `authorization.revoked`: the user disconnected your app; delete their tokens.
- `ping`: sent by **Send test event** in the dashboard.

```json
{ "id": "evt_…", "type": "collection.changed", "created_at": "2026-10-08T10:15:02Z", "user_id": "usr_…", "data": { "cursor": "chg_2kz" } }
```

Events never contain collection data. Delivery is at least once: deduplicate by the `Tangle-Event-Id` header. Answer `2xx` within 10 seconds; anything else is retried with backoff for about a day. Redirects are not followed.

**Verify every webhook.** The `Tangle-Signature` header is `t=<unix seconds>,v1=<hex HMAC-SHA256>` of `"<t>.<raw body>"`, keyed with your app's signing secret (`whsec_…`, in the dashboard). Reject events older than five minutes.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyTangleWebhook(rawBody: string, header: string, secret: string, toleranceSeconds = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

```python
import hashlib, hmac, time

def verify_tangle_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## Errors

Errors have Access's shape (https://www.tangle.music/omc/modules/access/#errors), with a `request_id` to quote and `param` naming the field when there is one. Branch on `code`, never on `message`. Every response carries `X-Request-Id`. Beyond Access's codes, Tangle answers:

| Status | `code` | What to do |
|---|---|---|
| 400 | `missing_version` | A request naming a position needs the list's `version`. |
| 403 | `backup_and_restore_required` | The user must turn on Backup and restore in Tangle. |
| 403 | `namespace_not_yours` | Another app owns that `ext` namespace (`param` names it). Use your own. |
| 409 | `version_conflict` | The list changed since the `version` you sent. Read it again (the current one is in `ETag`) and retry. |
| 409 | `list_read_only`, `list_not_reorderable`, `list_not_renamable`, `list_not_deletable`, `list_pending_deletion`, `list_combined`, `list_creation_unsupported`, `source_unavailable` | The list or platform can't take this change (check `can` first). Tell the user why. |
| 413 | `data_too_large` | Your namespace or a module's data is over its budget in `/me` `limits`. Nothing was cut. |
| 422 | `list_not_writable` | Folders, colours and smart lists are read only for apps. |
| 422 | `missing_url` | An item needs a copy on a platform, a `via` with a `url`, or identifiers. |
| 429 | `rate_limited`, `too_many_streams` | Wait `Retry-After` seconds. |

The OAuth endpoints answer with the standard `{"error": "invalid_grant", "error_description": "…"}` shape (RFC 6749).

## Rate limits

| Limit | Per minute |
|---|---|
| Reads per token | 600 |
| Writes per token | 120 |
| All requests per user (every token and app together) | 1,200 |
| All requests from one IP address | 3,000 |
| `GET /omc/v1/collection` | counts as 60 reads, for the token and the user; one at a time per user |
| List deletions per user and app | 10 per hour |
| Items removed from lists per user and app | 2,000 per hour |
| `POST /omc/v1/items` per user and app (shared with Captured) | 300 per hour |
| `POST /omc/v1/import` | 20 per hour, up to 20,000 records each |
| `/oauth/token` per app and IP | 30 |

Every response says where you stand: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the window resets).

## Recipes

### Tag every song by an artist

```bash
H="Authorization: Bearer $TANGLE_API_TOKEN"
TAG=$(curl -s https://api.tangle.music/omc/v1/lists -H "$H" -H "Content-Type: application/json" -d '{"name":"Ben Klock","ordered":false}' | jq -r .id)
curl -s https://api.tangle.music/omc/v1/items -H "$H" -G --data-urlencode "q=Ben Klock" --data-urlencode limit=100 \
  | jq '{items: [.data[] | select(.kind == "recording") | .id]}' \
  | curl -s https://api.tangle.music/omc/v1/lists/$TAG/entries -H "$H" -H "Content-Type: application/json" -d @-
```

### Build a playlist from a list of songs (Python)

```python
import os, requests

API = "https://api.tangle.music/omc/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['TANGLE_API_TOKEN']}"

def find(query):
    r = s.get(f"{API}/items", params={"q": query, "limit": 5}); r.raise_for_status()
    return [i for i in r.json()["data"] if i["kind"] == "recording"]

wanted = ["Strobe deadmau5", "Gravity Ben Klock"]
ids, missing = [], []
for q in wanted:
    hits = find(q)
    (ids.append(hits[0]["id"]) if hits else missing.append(q))

playlist = s.post(f"{API}/lists", json={"name": "From my notes", "ordered": True}).json()
for i in range(0, len(ids), 100):
    s.post(f"{API}/lists/{playlist['id']}/entries", json={"items": ids[i:i + 100]}).raise_for_status()
print(f"Added {len(ids)}; not in the collection: {missing}")
```

### Keep a local copy (TypeScript)

```ts
const API = "https://api.tangle.music/omc/v1";
const headers = { Authorization: `Bearer ${process.env.TANGLE_API_TOKEN}` };
const paths = { item: "items", list: "lists", source: "sources", record: "records" } as const;

async function download(store: Map<string, unknown>): Promise<string> {
  const res = await fetch(`${API}/collection`, { headers });
  for (const line of (await res.text()).split("\n")) {
    if (!line) continue;
    const record = JSON.parse(line);
    if (record.type !== "omc") store.set(`${record.type}:${record.id}`, record);
  }
  return res.headers.get("OMC-Cursor")!;
}

async function catchUp(store: Map<string, unknown>, cursor: string): Promise<string> {
  for (;;) {
    const page = await (await fetch(`${API}/changes?cursor=${cursor}`, { headers })).json();
    for (const c of page.changes) {
      const key = `${c.type}:${c.id}`;
      if (c.op === "delete") store.delete(key);
      else store.set(key, await (await fetch(`${API}/${paths[c.type as keyof typeof paths]}/${c.id}`, { headers })).json());
    }
    cursor = page.cursor;
    if (!page.has_more) return cursor;
  }
}
```

### An app that only adds

With `library:add` alone, an app (one that follows labels, one that reads music links from email) adds a recording and puts it in a list the user picked, without seeing the rest of the collection:

```bash
curl -s "https://api.tangle.music/omc/v1/items?list=lst_…" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{
  "kind": "recording", "title": "Night Drive", "artists": ["Mira Holt"],
  "copies": [{ "platform": "bandcamp", "ref": "night-drive", "url": "https://mira.bandcamp.com/track/night-drive", "ownership": "none" }]
}'
# {"result": "held", "new": true, "list": "not_added"} while it waits in Captured,
# or {"id": "trk_…", "result": "existing", "list": "added"}
```

A record shop's page isn't a copy: send it as `"via": { "name": "Hard Wax", "url": "https://…" }`, with identifiers (`ids`) when you have them.

### Move a song in a playlist

```bash
H="Authorization: Bearer $TANGLE_API_TOKEN"
V=$(curl -s https://api.tangle.music/omc/v1/lists/$PLAYLIST -H "$H" | jq -r .version)
curl -s https://api.tangle.music/omc/v1/lists/$PLAYLIST/entries/move -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg v "$V" '{from: 7, before: 0, version: $v}')"
# 409 version_conflict: someone changed the list since you read it. Read it again and retry.
```

### Send what was played

```bash
curl -s https://api.tangle.music/omc/v1/listens -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{
  "listen_type": "single",
  "payload": [{ "listened_at": 1793001600,
    "track_metadata": { "artist_name": "Mira Holt", "track_name": "Night Drive",
      "additional_info": { "isrc": "QZES72600001", "submission_client": "Example Player" } } }]
}'
```

The matched item's `play_count` and `last_played_at` change, and `/changes` reports it.

## Limits and guarantees

- Writes are atomic per request: a request that fails changes nothing.
- A response to a write already reflects the change, and its `OMC-Cursor` is the position just past it.
- Tangle does not store audio. Copies link to the platforms.
- The API is versioned in the path. Within `/omc/v1`, fields are only ever added, never renamed or removed; keep fields you don't know, as OMC requires.

## More

- Human-friendly docs: https://www.tangle.music/developers/docs/
- Open Music Collection Core 1.0: https://www.tangle.music/omc/spec/
- Open Music Collection Access: https://www.tangle.music/omc/modules/access/
- What belongs where (OMC, a home's choice, or one home's own): https://www.tangle.music/omc/spec/where/
- Dashboard (tokens, apps, connected apps; Tangle only): https://www.tangle.music/developers/dashboard/
- API terms: https://www.tangle.music/developers/terms/
