tangle
developers beta

This page is agents.md, laid out for reading. Agents can fetch the Markdown directly.

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.

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:

Shell
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:

ScopeConsent lineIn Tangle
library:readSee your collectionItems, sources and module records
lists:readSee your listsPlaylists and tags, with their entries
lists:writeChange your listsCreate, rename and delete lists; add, remove and move entries, and their notes; your app's data on lists. Includes lists:read
library:writeAdd to and change your collectionYour app's data on items and sources, the person's rating, module records, and importing. Includes library:read and library:add
library:addAdd music to your collectionAdd 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:writeTell Tangle what you playSend listens (see Listens)
listens:readSee what you've playedRead 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

RequestBody
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}/entriesOrdered: { "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}/entriesOrdered: { "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 onlyWhat to use for any OMC home
Registered apps and the developer dashboardAn app known by its address
Personal access tokensOAuth, 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:

Text
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):

Shell
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:

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

TypeScript
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:

StatuscodeWhat to do
400missing_versionA request naming a position needs the list's version.
403backup_and_restore_requiredThe user must turn on Backup and restore in Tangle.
403namespace_not_yoursAnother app owns that ext namespace (param names it). Use your own.
409version_conflictThe list changed since the version you sent. Read it again (the current one is in ETag) and retry.
409list_read_only, list_not_reorderable, list_not_renamable, list_not_deletable, list_pending_deletion, list_combined, list_creation_unsupported, source_unavailableThe list or platform can't take this change (check can first). Tell the user why.
413data_too_largeYour namespace or a module's data is over its budget in /me limits. Nothing was cut.
422list_not_writableFolders, colours and smart lists are read only for apps.
422missing_urlAn item needs a copy on a platform, a via with a url, or identifiers.
429rate_limited, too_many_streamsWait Retry-After seconds.

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

Rate limits

LimitPer minute
Reads per token600
Writes per token120
All requests per user (every token and app together)1,200
All requests from one IP address3,000
GET /omc/v1/collectioncounts as 60 reads, for the token and the user; one at a time per user
List deletions per user and app10 per hour
Items removed from lists per user and app2,000 per hour
POST /omc/v1/items per user and app (shared with Captured)300 per hour
POST /omc/v1/import20 per hour, up to 20,000 records each
/oauth/token per app and IP30

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

Recipes

Tag every song by an artist

Shell
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)

TypeScript
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:

Shell
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

Shell
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

Shell
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