Home Pricing Docs Compare SDK Blog Changelog Status Contact Get API Key →

What we shipped

Changelog — in public.

Every meaningful change to the Music Metadata API: new endpoints, new fields, expanded catalog coverage, and reliability fixes. Most recent first.

Karaoke versions no longer stand in for the original

When a track was matched against Apple’s catalogue, a karaoke or soundalike re-recording could occasionally win the match — and once it did, its audio was what got analysed. You would get a normal-looking response with the right title and often the right artist name, but the bpm, key, energy and everything else described a different recording.

This was the worst-behaved miss in the matcher, because nothing about the response looked wrong. A karaoke release carries the correct title, is frequently credited to the original artist, and has a real preview to analyse. In the case that prompted the fix, the karaoke listing out-scored the genuine track.

These releases are now rejected during matching rather than scored down. If the real recording is available you get it as before; if it isn’t, you get an honest miss instead of another recording’s numbers. For anyone who wants the karaoke version, asking for it by its full title and label still returns it — the rule only applies when you didn’t ask for one.

  • Detection is deliberately narrow, so ordinary tracks are unaffected: phrases like “Originally Performed By”, “In the Style Of” and “Backing Track”, or karaoke inside a bracket. A song simply called “Karaoke”, or titles containing “tribute” or “cover”, are not treated as karaoke.
  • Across the current catalogue this classifies 0.05% of tracks — all of them from karaoke and backing-track labels.
  • If a track you rely on starts returning a miss after this change, send it over — that would mean the match it was previously getting was a re-recording.

A thin answer now upgrades itself

Intelligent Backfill used to trigger only on a total miss — when no layer could resolve a track at all. If a track resolved through one of the fallback datasets (AcousticBrainz, FMA, the Million Song Dataset), that counted as a resolution and the pipeline stopped there. You got a real answer, but a thin one: those datasets carry no energy, valence, speechiness, liveness, loudness_db, time_signature or confidence scores, and nothing ever went back to fill them in.

Now, serving one of those thin answers quietly queues a real analysis in the background. The response you get is unchanged — same data, same speed, nothing new to handle — and the next time that track is looked up it comes back with the full field set and feature_source: "essentia_preview".

This mostly shows up on catalogue that MusicBrainz covers well but the streaming charts don’t: game and film soundtracks, older reissues, regional catalogue. It happens on its own, so there’s nothing to opt into.

  • Tracks with no analysable source anywhere still return what the fallback layer holds — a thin answer remains better than none.
  • As with the ordering fix above, an upgraded track’s itunes_track_id moves from the synthetic mb:<uuid> form to a real catalog ID. Store isrc or mbid if you need a stable key.

If we analysed your track, you now get the analysis

When a track isn’t in our own catalog, /lookup falls back to AcousticBrainz, FMA or the Million Song Dataset. Those datasets are a genuinely useful safety net, but they are thinner than our own analysis — AcousticBrainz, for instance, carries no energy, valence, speechiness, liveness, loudness_db, time_signature or confidence scores at all.

The bug: if a track first resolved through one of those fallback layers and was later fully ingested and analysed by us, the lookup kept returning the older, sparser row — permanently. The good analysis existed and simply never got served. Several lookup paths picked between the two rows with no tie-break, so whichever row was created first won, forever. It affected lookups by name and by ISRC alike.

About 1,300 tracks were in that state, including plenty of well-known catalogue. They now return the full field set.

  • Eight fields appear where they were previously null on affected tracks — energy, valence, speechiness, liveness, loudness_db, time_signature, bpm_confidence and key_confidence.
  • feature_source changes from acousticbrainz (or fma / msd) to essentia_preview on those tracks — which is the honest label for where the numbers now come from.
  • itunes_track_id changes on those tracks, from the synthetic mb:<uuid> form to a real numeric iTunes ID. The old IDs still resolve, but they point at the old sparse row — so if you cached IDs, re-fetch rather than reusing them. isrc and mbid are unchanged and are the stabler keys to store.
  • bpm and key may shift slightly on affected tracks, because they are now measured from the audio rather than read from a frozen dataset. Differences are small and occasionally a key is reported as its relative major or minor — the same key signature under a different convention.

Tracks that only ever existed on a fallback layer are unaffected and still return what that layer holds — a sparse answer is still better than none.

Retry the failures worth retrying, and stop re-queueing the ones that aren’t

When an on-demand ingest couldn’t complete, the ingest.failed webhook told you that it failed and gave you an English sentence explaining it. Useful in a log; useless in code. There was no way to tell “this track isn’t on any streaming service and won’t be next week either” from “a source rate-limited us for a moment” — so the only safe thing a pipeline could do was pick one backoff and apply it to everything.

The event now carries the same machine-readable verdict a terminal /lookup 404 has carried for a while: terminal (true / false), a reason enum, and retry_after_days when the verdict is terminal. The English sentence moves to message, where it belongs.

  • terminaltrue if the verdict won’t change yet, false if it’s worth another attempt
  • reason — the machine-readable code, e.g. not_on_streaming
  • retry_after_days — how long to wait (terminal verdicts only)
  • message — the English sentence, for your logs

Terminal reasons are not_on_streaming (no match on Apple Music or Deezer — CD-only, unreleased, off-streaming, very regional) and no_preview_audio (we matched it, but the release has no 30-second preview clip and features are computed from preview audio). Transient ones are throttled, cdn_forbidden, download_failed, analysis_failed and internal_error — all worth another attempt, and all retried by us as well.

Those first two are the same strings the /lookup 404 uses, with the same terminal and retry_after_days meaning, so one branch in your client handles both surfaces. Full table in the SDK & webhook docs.

One correction shipped with it: a transient rate-limit used to send the “this track isn’t available on any streaming source” message. It was never true — the track was fine, we were being throttled — and it now says so.

If you parse the webhook: reason used to hold that English sentence and now holds the enum; read message for the prose. Treat the enum as open — default anything you don’t recognise to “retry later” rather than rejecting the delivery.

Errors now tell you how to fix them, not just that something failed

Two of our error messages named the problem without naming the remedy, and the pattern in the logs was unmistakable — people hit them once and gave up.

An unrecognised or missing key returned only “Invalid or missing API key”. It now names the header to send the key in, shows a working curl example, and links to a free key. A key that exists but has been deactivated already had its own distinct message; that is unchanged.

Sending the wrong body shape to POST /bulk returned the raw validation output — “Input should be a valid list” — which is accurate and almost useless. It now tells you the body is a bare JSON array of objects and shows one, including the case where you send an array of bare ISRC strings instead.

Nothing about the requests themselves changed: same status codes, same fields, same behaviour — only the wording you get back when something is wrong. The 422 on /bulk is now described properly in our API reference as well, instead of falling back to a generic “Validation Error”.

Tempo search no longer reads the whole catalogue to answer

Searching for tracks near a given tempo took around 5 seconds. The reason was unglamorous: the tempo lived inside a JSON blob with no index on it, so every request read all 1.1 million catalogue rows and re-parsed the tempo out of each one — three times per row. It was written when the catalogue was a fraction of its current size, and quietly stopped being appropriate as we grew.

Tempo is now an indexed column, so a request jumps straight to the matching range instead of reading everything. Typical calls now return in about 1.5 seconds. Most noticeable through the hosted MCP connector, where find_tracks_by_bpm was the slowest tool by a distance.

Results are unchanged — same tracks, same order, same parameters. This was purely how we fetch them.

A track we don’t hold no longer takes fifteen seconds to say so

/tag returns tags for a track we’ve already analysed. If you asked for one we don’t hold, we would first go and try to resolve it against the external music databases — the same work /lookup does — and only then come back to say we couldn’t tag it. The worst case we measured was 14.7 seconds, ending in a 404 that asks you to call /lookup, which would then repeat that same resolution. A long wait for an answer that sends you somewhere else.

That resolution is now capped at 5 seconds. Same 404, same message, without the wait. If you do want the full resolution — including queueing the track for on-demand analysis — /lookup still does it uncapped, which is exactly what the 404 points you at.

Nothing changes for a track we do hold. Half of all successful /tag calls finish in 2 ms and 99.9% inside 0.28 s, so the cap sits about 18× beyond anything a healthy lookup needs; checked against every /tag call in the endpoint’s history, a single one would have been affected. Malformed requests are unchanged too — they still fail immediately with their own message rather than being reported as a miss.

Most likely to show up if you use the hosted MCP connector, where an assistant can ask for a track title that doesn’t exist.

An unrecognised file now gets a decode attempt, not an instant 415

Uploads were checked against a list of audio types we recognise by their magic bytes. If your file didn’t match the list it was rejected with 415 Unsupported Media Type — even when it was a perfectly ordinary track we could read without any trouble. The type sniffer is simply not that reliable: an MP3 with a bit of padding before its first frame comes back as “unknown binary”, and so does a WavPack file.

Now an unrecognised file gets a second opinion. We try to decode a few seconds of it, and it’s only rejected if that genuinely fails. Four formats that used to 415 on upload now work: padded MP3, WavPack, Wave64, and raw ADTS .aac — that last one matters, because AAC was always listed as supported but a bare .aac file never actually made it through.

Files that aren’t audio are still rejected immediately, with the same 415 and the same message telling you what we detected. Nothing changes for a file that was already accepted, and the check costs about a tenth of a second on the rare upload that needs it. This applies to /identify too, whose “any format ffmpeg can decode” promise is now true.

A track we can’t analyse now says so on the first call

When you look up a track we don’t hold yet, we queue an on-demand analysis, and ?wait=N holds the request so the finished track comes back in that same call. But the wait only ever watched for the track appearing. If the analysis instead gave up — because the track isn’t on any streaming source we can pull preview audio from — nothing was watching for that. The request sat out its full wait and then returned 202 queued, re-poll in 15s for a track that was never going to arrive.

In a real case this week the verdict landed after 7 seconds and the caller was still held for 24, then told to come back. Now the wait notices the verdict and returns the honest 404 straight away — terminal: true, a reason of not_on_streaming or no_preview_audio, and a Retry-After — the same answer a re-poll would have given you a minute later. That same lookup now finishes in 12 seconds with a real answer instead of 25 with a false one, and the call is not charged against your quota: we don’t bill for “we have nothing for you”.

Nothing changes when the track does analyse in time — you still get the full 200 inline, and a landed track always wins over a stale verdict. This only replaces the one answer we knew was wrong.

Off-genre picks are pushed down, not merely un-helped

GET /recommendations has been genre-aware since June, but its rule was narrow: a candidate got a lift only when its genre string matched one of your seeds exactly. Anything else got nothing — including tracks we can tell are confidently the wrong family. Un-helped is not the same as demoted, so a reggae record could still sit in the top three for a mainstream pop seed purely because it landed close on tempo, energy and key. The same narrowness under-credited genuine matches too: an alternative seed gave an indie rock track no lift at all, despite the two being the same family.

It now uses the same family-level genre ranking as /similar and /next-track: same-family picks get a gentle lift, mixable neighbouring families are neutral, and confidently incompatible ones are pushed down. Measuring 120 random seeds before release, the top ten went from 85% to 96% same-family and cross-family picks fell from 9% to none.

Two things deliberately did not change. Cross-family tracks are still returned — they sink rather than disappear, so a niche seed never comes back with a shorter list (we re-verified that across 150 seeds). And cross_genre still works exactly as before: allow for the genre-blind ordering if you preferred it, strict to drop off-family picks server-side.

Your results will move. Ordering changed for most seeds, and because lower-ranked cross-family tracks are displaced by in-lane ones, the set of tracks you get back changes too — not just the order. score is still the raw audio-feature cosine, so it remains normal for the list not to be strictly score-descending. If you depended on the old behaviour, cross_genre=allow restores it.

If you pass more than one seed, the lane considers all of them: a track that shares a family with any of your seeds stays in lane, so blending deliberately across genres still works.

genre_relation now works on /recommendations too

Yesterday we added genre_relation to /similar. It lives in the same result shape that GET /recommendations returns — but that endpoint never filled it in, so for a day every recommendation came back unknown. If you read the field and concluded we had no genre information for any of your results, that was our bug and not your data.

It now carries a real verdict on every result: same, compatible (a different but mixable family), cross (unrelated), or a genuine unknown when either side has no mapped genre. /recommendations accepts up to five seeds, so the comparison is made against your first seed — specifically the first one we could actually use, so a seed we had to skip (not in the catalogue, or not analysed yet) never becomes the reference. Reordering seed_tracks therefore changes the label, which is intended.

Worth knowing if you filter on it: on /similar and /next-track this field is the value the ranking uses. That became true for /recommendations too later the same day — see the entry above, which replaced that endpoint’s narrower same-genre boost. With a single seed the field now explains the order; with several seeds it stays relative to your first seed while the ranking considers all of them, so a cross label on a multi-seed call does not mean the track was pushed down.

Additive: score, count, seeds and the order of results are all unchanged, so there is nothing to do on your side.

A short outage on your side no longer switches webhooks off for good

If your webhook endpoint fails repeatedly we stop POSTing to it — that part is deliberate, and it protects you from a flood of retries against a receiver that is already struggling. What was wrong is what happened next: that pause never lifted. Once an endpoint had failed 15 deliveries in a row (each already retried three times), we would not try it again, even after it recovered, until someone here reset it by hand.

A half-hour outage could therefore cost you days of events. It did exactly that to one customer: their receiver returned 500s for about thirty minutes, was healthy again inside the hour, and still missed 6,910 completion events over the following six days before we noticed. That is our bug, not theirs, and it is fixed.

Delivery is now paused only temporarily. While an endpoint is in that state we send one event through every hour as a probe; the first one you accept with a 2xx resumes normal delivery immediately. Nothing to configure, no need to re-register your URL, and no change to the payloads or signatures. We also alert ourselves now when a customer's endpoint is failing, so a genuinely broken receiver gets a message from us instead of silence.

Worth knowing either way: a dropped webhook is never lost data. Every ingest still lands in the catalogue, so /lookup returns the full record whenever you ask for it — a missed notification only ever costs you the push, not the analysis.

Whole-number BPM, because that’s what DJ software gives you

A customer building a DJ app pointed out that our BPM comes back as 127.89 where every DJ tool would say 128 — and he was right about why. Traktor, Serato and Rekordbox report clean integers not because they measure more precisely, but because they derive a beat grid, and a grid has exactly one tempo by construction. Our bpm is a measurement of the audio, so it carries a decimal. Those are two different kinds of number, and until now we only gave you one of them.

Every response that carries bpm/lookup, /analyze and the batch endpoints — now also carries bpm_snapped: the same tempo rounded to a whole number. Always populated, never null. Half values round up, so 138.5 is 139 rather than the 138 that banker’s rounding would give.

Use whichever fits: bpm_snapped for library organisation and anything that should look like the rest of a DJ’s tooling, bpm when you’re beatmatching by ear and the difference between 127.89 and 128.00 is exactly the point, and bpm_confidence to judge how far to trust either. Nothing existing changed — this is purely additive.

A new genre_relation on every result

Since 27 July /similar has kept results in a mixable genre lane by default. What it didn’t do was tell you which results were in that lane — and because the ordering is cosine plus a genre adjustment while the response carried only the raw cosine, the numbers didn’t explain the order. A list could descend 0.983 → 0.957 and then jump back to 0.975, which looks broken unless you know why.

Every result now carries genre_relation: same, compatible (a different but mixable family), cross (unrelated), or unknown (one side has no mapped genre, so no adjustment was applied). Where the seed’s genre is thinly represented we still pad the list out to your limit with lower-ranked cross-family picks rather than returning short — but now you can see exactly where that starts, and filter on it. If you’d rather we did the filtering, cross_genre=strict still returns same-family only.

Additive: ordering, score and count are unchanged, so existing integrations need no action.

A waiting request no longer holds a concurrency slot

When /lookup misses, we queue an on-demand ingest. Paid keys then wait a few seconds so the track usually comes back inline (200) rather than as a 202 you have to re-poll. That is the right behaviour for a single lookup — but until now the request held one of your key’s concurrency slots for the whole wait, even though it was only sleeping.

For anyone importing a catalogue, where nearly every lookup is a miss, that turned the concurrency limit into a throughput ceiling far below the intended rate, and produced 429s on a key that was well inside its plan. The slot is now handed back before the wait begins, so slots turn over at the speed of real work instead of ingest latency. Waiting requests are still bounded, separately and much more generously; past that bound you simply get your 202 immediately, which is what a bulk client wants anyway.

And the responses now tell you which mode you want

The 202 used to suggest only “add ?wait=10 to hold the request”. Correct for one interactive lookup; exactly backwards for an import. Both the 202 and the concurrency 429 now spell out the choice: one track → ?wait=10; whole catalogue → ?wait=0 (each miss returns instantly, then re-poll poll_url) or POST /bulk for up to 50 tracks per call.

No request or response fields changed — existing integrations need no action.

A seedable flag on search hits

GET /search returns any track in the catalogue, but the set-builder endpoints — /recommendations, /similar, /next-track, /setlist and /transition — seed off our similarity index, which holds only tracks we have analysed. Roughly a quarter of the catalogue isn’t analysed yet, so a search hit could be a perfectly valid id that still couldn’t be used as a seed. You only found that out from a 404.

Every /search hit now carries a seedable boolean. Pick the highest-ranked hit with seedable: true and the set endpoints will accept it. This is additive — every existing field is unchanged.

Seed 404s now name the real cause

Passing an unanalysed id previously returned “Seeds must be FreqBlog catalogue itunes_track_ids (from GET /search…), not track names” — which was misleading, because a catalogue id from /search was exactly what had been passed. The same applied when seeding by name: the error said the track couldn’t be resolved when in fact it had been.

Both now say what actually happened — the track is in the catalogue but has no audio analysis yet — and give you the two ways forward: call GET /lookup on it to queue analysis and retry, or pick a hit with seedable: true. A genuinely unrecognised id still gets the original message.

Both MCP servers now surface the flag in their search tool descriptions, so assistants pick a usable seed rather than discovering the problem by failure.

Three fixes to file uploads

AIFF is now accepted. POST /analyze and POST /identify take MP3, WAV, AIFF, OGG, FLAC and AAC/M4A up to 15 MB. AIFF was rejected with a 415 purely because it was missing from our accepted-types list — the analyser could always read it. If you export from Logic, Ableton or Rekordbox, you no longer need to convert first. The fallback.accepts array on terminal /lookup 404s lists it too.

Files shorter than 60 seconds now work. Uploads under a minute previously came back 422 “Audio analysis failed”. We were always reading a fixed 30-second window starting at the 60-second mark, so a shorter file left nothing to analyse. We now analyse the most representative part of whatever you send — the highest-energy 30 seconds — which also means intros and quiet openings no longer skew the result on longer tracks.

The first upload after a deploy no longer times out. Our audio stack compiled itself on first use, so the first analysis following a restart could exceed the 10-second limit and return 504, while every later call took about a second. That compilation now happens at startup, before we accept traffic. If you ever saw an isolated 504 on /analyze that succeeded on retry, this was why.

No request or response fields changed. Existing integrations need no action.

The “not on streaming” 404 now points at /analyze

When a ?track= lookup terminally fails, it’s because the recording isn’t on a streaming service we can pull audio from — CD-only, event-only, unreleased or bootleg material, royalty-free libraries, or catalogue pulled from streaming. We compute features from audio, so with no audio there is nothing to analyse.

That 404 explained the dead end and stopped there. It now names the way out: if you hold the audio file yourself, upload it to POST /analyze and we compute the full feature set from your file. The response carries a structured fallback object alongside the existing terminal, outcome, reason and retry_after_days fields, so a client can branch on it rather than parse prose:

  • fallback.endpointPOST /analyze
  • fallback.accepts — MP3, WAV, OGG, FLAC, AAC/M4A (up to 15 MB)
  • fallback.quota_cost — 1 request

The same route is now spelled out in the “could not ingest” email you get after a failed on-demand ingest.

One caveat we’d rather state than hide: features from your upload are computed from your own file, while catalog features come from a 30-second streaming preview. It’s the same analyser and the same window length, so the numbers are comparable for sorting, filtering and mixing decisions — but they aren’t bit-identical, and we don’t blend the two. fallback.note says so in the response.

detail remains a plain string, and every existing field is unchanged — this is additive.

acousticness is now a real range instead of “0.98 for everything”

Our acousticness is derived from spectral flatness, and the constant it was scaled against was far too large — real music sits near 0.003, not the 0.15 we divided by. The effect: 98% of the catalogue came back at roughly 0.98, so techno and solo guitar alike reported as “almost fully acoustic”. Measured across 831,745 analysed tracks the values spanned a 5-point band (p25 0.9365, p50 0.9718, p75 0.9900).

The ordering was always there, just squeezed into the top of the range, so we rescaled it against the distribution of Spotify’s own acousticness over a 114,000-track reference set. Values now spread properly (p25 0.017, p50 0.169, p75 0.598) and mean absolute error against Spotify drops from 0.74 to 0.27. Existing thresholds you may have written against the old values will need revisiting — previously almost everything cleared > 0.9.

It also fixed something bigger. Because the old column was nearly constant, the minority of tracks carrying correctly-scaled values sat 6–8 standard deviations out, and that one feature came to dominate their audio-similarity vectors. That split /similar into disconnected pools: seeding one of those tracks could only ever return others like it, leaving most of the catalogue unreachable. With the scale corrected those tracks sit inside the distribution and the whole catalogue is reachable from any seed.

Being straight about what this is: acousticness is a spectral measure, not a trained classifier. It now shares Spotify’s scale, so thresholds transfer, but per-track agreement is a rank correlation of about 0.34 — a highly tonal synth pad can still read as acoustic. Use it to sort and filter, not as a verdict on any single track. Tracks whose features come from AcousticBrainz, FMA or MSD were already correctly scaled and are unchanged.

Better /similar results — one row per recording, and in a mixable genre lane

GET /similar ranked purely on acoustic closeness, with no sense of genre. That is fine in theory and poor in practice: seeding an electronic track could return a string-quartet cover of a metal song and a background-music channel near the top, because they genuinely sat close on tempo, energy and key. /next-track and /recommendations were given a genre lane in July; /similar never was. It has one now, with the same cross_genre control — auto (the new default) keeps results in a mixable lane, strict limits them to the seed’s genre family, and allow restores the previous genre-blind ranking if you preferred it.

Duplicates are gone. The same recording is often in our catalogue several times over, once per storefront release. Those rows scored almost identically, so a single track could occupy many of your result slots — in the worst case we saw, 7 of 15. Results are now one row per recording, so a 15-result request returns 15 distinct tracks.

Thinly-analysed tracks no longer crowd out better matches. Scoring now compares each candidate to the seed only on the features they actually share, and re-normalises on that basis. Previously a track with few analysed features concentrated its whole score into those few, which could push it above a fully-analysed, closer match.

No changes are needed on your side. If you depend on the exact previous ordering, pass cross_genre=allow.

Large ISRC migrations are dramatically faster — plus one note on how tracks are named

Two separate issues were slowing GET /lookup?isrc= during sustained migration runs — the kind that fires thousands of ISRC lookups back to back. First, when resolving an ISRC to a track we queried MusicBrainz before Deezer, even though Deezer resolves far more ISRCs for us. MusicBrainz also sits behind a small internal concurrency limit, so under a heavy migration the wait for that limit — not the lookup itself — became most of the response time. Second, for a track not yet in the catalogue, a background re-check was scanning the full catalogue every 1.5 seconds while it waited for analysis to land, consuming shared capacity that other callers needed.

Both are fixed. Under sustained migration load, ISRC lookups that were taking 20–50 seconds now return in roughly 1.5–2 seconds. ISRCs already in the catalogue are unchanged at around 100 ms. No code changes are needed on your side — if you are looping single /lookup?isrc= calls, that now runs at the rate you were trying to run it. POST /bulk (up to 50 items per call) and POST /bulk-csv (up to 500 rows) are still fewer round trips if you prefer them.

Non-Latin titles — CJK, Hangul, Cyrillic, Arabic, Thai: Deezer romanises them, returning Eien no Blue where our catalogue stores 永遠のブルー. On its own that would have caused misses, not just different-looking titles: a romanised name doesn’t match a native-script catalogue entry, so a track we already hold would have come back as a queued 202. We fixed that in the same release — when a Deezer name fails to match and the ISRC is registered in a non-Latin market, we take a second opinion from MusicBrainz and resolve to the native title. Japanese, Korean, Chinese and similar repertoire keeps returning native script, and gets the speed improvement too. A small number of tracks whose ISRC MusicBrainz doesn’t carry may still come back romanised.

Much faster, steadier lookups for high-volume & migration workloads

As our catalogue grew past a million tracks, very high-volume lookups — especially large ISRC-based library migrations firing many concurrent GET /lookup?isrc= calls — could slow down and, under sustained load, occasionally error. We’ve reworked how those lookups resolve so a catalogue hit now returns in a few milliseconds under the bulk load that previously slowed it, moved that work off the hot request path so one slow query can’t hold up other callers, and added per-network burst protection. (A further cause, affecting lookups for tracks not already in the catalogue, was found and fixed on July 26 — see the entry above.) Existing calls are unchanged — just faster. If you’re moving a large library, POST /bulk (ISRC or name items, up to 50 per call) and POST /bulk-csv (up to 500 rows) remain the most efficient path.

Bulk & migration workloads no longer crowd out live lookups

If you were loading a large catalogue — via POST /bulk or a run of GET /lookup?isrc= / ?track= calls — a track that is available could occasionally come back as a transient 404 or miss while the batch was in flight, because a large import could momentarily exhaust our upstream resolution capacity. We’ve substantially increased that capacity and added safeguards that ring-fence a slice for live, interactive lookups and for the on-demand analysis that recovers a track — so a big migration no longer starves them. Net effect: bulk and migration runs resolve more consistently, and a track that’s on a streaming source we analyse is far less likely to bounce under load. If you saw the occasional miss during a large import, just re-run those items and they’ll resolve.

/similar, /next-track, /radio, /transition & /setlist now take the same identifiers as /lookup

You no longer need a catalog itunes_track_id in hand to seed the set-flow endpoints. Seed straight from a /lookup response by isrc (pass it as isrc= — or even paste it into the id field, we detect it), by spotify_id, or by track+artist name. And the seed parameter is now consistent across every set endpoint — track_id, seed_track_id and itunes_track_id (the field name a /lookup response returns) are all accepted. When a seed can’t be resolved the error now tells you how to fix it (e.g. “that looks like an ISRC — pass it as isrc=”) instead of a misleading “no audio analysis”. And a freshly-ingested track becomes seedable within minutes of analysis now, rather than only after a restart. Existing calls that pass a catalog id are unchanged.

On a name miss, GET /lookup can return the analysed track in the same request

When a track isn’t in the catalog yet we queue an on-demand analysis and return 202. Two changes so you don’t have to come back for it: (1) the 202 now carries a poll_url (and a Location header) — the exact URL to re-poll, which resolves to 200 once ready; and (2) add ?wait=N (0–25s) to hold the request until the ingest lands and get the features back inline (HTTP 200) instead of a 202. Paid keys now apply a small default wait automatically, so a fast-resolving miss comes back with the data in one call; a track that needs longer to analyse still returns the 202 to re-poll (or follow the poll_url). The wait is bounded well under the gateway timeout, so it never hangs. Our hosted MCP connector and the music-metadata-mcp npm package (v2.8.0) use this automatically.

Register a webhook and we’ll POST you ingest.completed / ingest.failed events

Configure an HTTPS callback with POST /me/webhook and a signed JSON event fires when your on-demand ingests finish (not just field-fill backfills), each carrying a result_url you can GET for the full feature set — ideal for pipelines that fire a miss and walk away. Deliveries are now signed with a timestamp-bound X-FreqBlog-Signature (send the X-FreqBlog-Timestamp header to your verifier and reject anything older than ~5 min), callback URLs must be public https hosts, and a dead endpoint is automatically backed off. The Python and Node SDK verify_webhook()/verifyWebhook() helpers take the timestamp argument — update to the latest.

Four new fields on every lookup: is_remix, remixer, mix_name, remix_of_isrc

GET /lookup, POST /bulk and GET /v1/audio-features now return remix labelling parsed from the track title — is_remix (true for a third-party remix or bootleg, false for originals and same-performance versions), the remixer credit when attributable, and the mix_name version label (e.g. Extended Mix, Alesso Remix). Plus remix_of_isrc — the ISRC of the original recording a remix is based on, so you can jump straight from a remix to its source. It’s metadata parsed from the title — no audio analysis needed, so it’s available the moment a track resolves. Populated for catalog remix and version rows; null on fallback-layer tracks. This brings the response to 42 fields per track.

The Spotify-shape /v1/audio-features/{id} endpoint no longer stalls under a heavy burst

If you migrate a large library by firing many single GET /v1/audio-features/{id} calls at once, a big burst could previously pile up and run past the gateway timeout. Two changes fix that: requests now pace per key (a burst is smoothed rather than overwhelming the resolver), and each call is time-bounded — if a track can’t be resolved quickly under load you now get a fast, retryable 503 with Retry-After instead of a request that hangs. Normal fast lookups are unchanged.

For large jobs, the batch forms remain the fastest path: GET /v1/audio-features?ids= (up to 100 comma-separated IDs) and POST /bulk (up to 50 per call) both time-box and return partial results — unresolved entries come back empty and uncharged, so you collect the whole list in a couple of passes with no timeouts.

On-demand ingest is faster — and you can now get the result in a single call

When you look up a track we don’t have yet, we fetch its preview from iTunes, run full analysis, and add it. That now typically completes in ~15 seconds (always under a minute), down from up to a minute before. If you poll the 202 Queued response, its Retry-After is now 15s (was 30s), so an automated re-poll usually lands on a ready track first try.

New — an opt-in ?wait= parameter on /lookup. Add &wait=15 (up to 25 seconds) and, on a miss, we’ll hold the request while the track ingests and return the fully analysed track in that same call (200 OK) — no 202, no re-poll. If it isn’t ready in time you get the usual 202 to re-poll, exactly as before. Example: GET /lookup?track=Yekermo+Sew&artist=Mulatu+Astatke&wait=15. Leave wait off (the default) and nothing changes — you get the instant 202 and poll on your own schedule.

Free on-demand is now unlimited within your monthly quota

We’ve removed the free tier’s daily fair-use cap on fetching tracks that aren’t in the catalogue yet. Every free /lookup for a track we don’t have gets analysed on demand and added — you’re now bounded only by your monthly quota (1,000 requests/mo on Free), with no separate per-day limit and no mid-day pause. Building the catalogue as you go is exactly what we want free users doing.

We also raised the free per-minute rate limit from 20 to 50 requests/minute (this applies to the upload endpoints /analyze and /identify; /lookup, /bulk, /bpm and /key stay quota-only). And paying customers now get priority on /bulk: a paid key gets a larger concurrent-batch lane and, when it’s full, its batches wait briefly for a slot instead of being turned away — free batches shed first under contention. Your monthly quota, the per-key /lookup concurrency cap, and all pricing are unchanged.

Your dashboard spots the snags before they cost you

Your usage dashboard now watches your own traffic and surfaces a tip exactly when it helps. Looking up raw Spotify IDs that mostly 404? It points you to name/ISRC lookups (a raw Spotify ID resolves only for the ~2.4% of the catalogue we’ve mapped to one). /bulk batches running past the 25s limit and coming back partial? It tells you to send ~15–25 tracks per call and re-request the rows still marked processing (those aren’t charged). A run of requests failing validation (422)? It shows how many and where the 422 body names the field to fix. At most two tips show at once, so it never becomes a wall of warnings.

We also made the limits and costs you’re billed on visible up front: the plan panel now spells out that most calls cost 1 request while the set-tools cost more (/setlist 5, /next-track 3, and /recommendations / /identify / /related-artists 2), names the per-key /lookup concurrency cap (a 429 there is a “just retry”, not a quota charge), and states that your monthly quota resets at 00:00 UTC. A null audio-feature field now reads as “we don’t have this for that track” rather than looking like a bug, and there’s a direct link to the MCP server for Claude / Cursor. Behaviour is unchanged — just clearer.

/recommendations now seeds by name, not just by id

/recommendations takes catalog itunes_track_ids as seeds — which meant a two-step dance if all you had was a track and artist: look it up, grab the id, then recommend. You can now pass track=<title>&artist=<artist> directly and we resolve it to the best catalogue match and use that as the seed. The track we picked comes back as seed_query so you can confirm we matched the one you meant.

Passing seed_tracks still works exactly as before and wins if you send both. A name we don’t have yet returns a clear 404 that points you at /lookup (which queues it for analysis) instead of a bare “not found”. We also pointed /docs and /docs.html at the live API reference, so a guessed docs URL no longer dead-ends.

/recommendations now takes cross_genre — same-genre-only when you need it

/recommendations already re-ranks by genre affinity so a sonically-close but cross-genre track doesn’t outrank your same-genre picks. It now accepts the same cross_genre parameter /next-track got — auto / strict / allow — so you can drop off-genre matches server-side instead of post-filtering on the genre field yourself.

cross_genre=auto (default) is unchanged: the genre-aware ranking, with cross-genre picks still appearing so the result is never shorter. cross_genre=strict returns same-genre-family tracks only — off-genre picks are removed from the response (with a safe fallback so a niche seed never comes back empty). cross_genre=allow disables the genre ranking for pure audio-feature similarity. The score is still the raw cosine similarity — only which tracks come back, and their order, changes. Available on the API and the MCP get_recommendations tool.

/next-track is genre-aware — no more country songs after a house record

/next-track used to rank suggestions on Camelot key + BPM + energy alone, so a track that only coincidentally shared the seed's key and tempo could top the list — e.g. a country or latin record landing right after an electronic seed. Great harmonically, useless in a real set.

It now keeps picks in a mixable genre lane by default (cross_genre=auto): off-genre matches sink to the bottom and appear only if too few in-genre picks exist, so you never get fewer results. Each suggestion now also carries its genre and genre_relation (same / compatible / cross). Use cross_genre=strict for same-genre only, or cross_genre=allow for the old harmonic-only behaviour. The raw mix score is unchanged — only the ordering improved.

/bulk now skips the odd item instead of rejecting the batch

Each /bulk call processes up to 50 tracks. Previously, sending more than 50 items — or a single row that carried neither a track name nor an isrc — rejected the entire request with a 422, so one stray row could silently drop a whole batch of good lookups.

Now those items are simply skipped, not fatal: every input is echoed back in order, items past the 50-per-call cap come back with found: false and backfill_status: "over_limit", and rows missing both identifiers come back as "invalid_no_query" — none of them are charged. A new skipped count in the response tells you how many to resubmit (in batches of ≤ 50). Requests larger than 200 items still return a clear 400.

“Not available” now tells you why

Audio features are computed from a track’s commercial streaming preview audio (Apple Music / Deezer). A small slice of music simply isn’t on any streaming source we can reach — CD-only or event-only releases, unreleased or bootleg material, and catalogue that’s been pulled from streaming (common for some underground indie, idol and pre-1980s recordings). There’s no audio to analyse, so we can’t return features — that’s a gap in the world’s commercial availability, not a bug in our matching (we deliberately return nothing rather than guess a wrong track).

When the pipeline has already tried a ?track= lookup and found no analysable source, repeat calls now return a clearer terminal 404 carrying outcome: "unavailable" and a machine-readable reasonnot_on_streaming (no match on any streaming catalogue) or no_preview_audio (a match exists but has no 30-second preview clip) — alongside the existing terminal: true and Retry-After. The dashboard, the “couldn’t ingest” email and a new FAQ explain the same thing in plain English. Everything with a commercial streaming release still resolves in ~30 s–2 min via Intelligent Backfill; and you can always analyse a file you hold yourself via POST /analyze.

/export now speaks Serato, Traktor, Engine, CSV & M3U8

GET /export/{format} gained two new formats and three aliases, so a harmonic set drops straight into whatever you mix on:

  • csvthe only format that carries every field: BPM, key, Camelot, energy, danceability, valence, mood, genre, ISRC and more, one row per track. Opens in Excel, Sheets or pandas.
  • traktor — a native Traktor NML collection + playlist, with structured BPM and key on every track.
  • serato and engine — accepted as aliases for the Rekordbox XML export, which Serato DJ and Engine DJ both import with the BPM/key intact (the cleanest path for your structured data).
  • m3u8 — accepted as an alias for m3u.

The existing rekordbox, m3u and cuesheet formats are unchanged. Chain it: POST /setlistGET /export/<format>?track_ids=<ordered ids>. Still one request per call.

/bulk shrugs off list-format artists; /lookup accepts title

If your pipeline sends the artist as a list — a JSON array ["A","B"] or a Python-style "['A','B']" string, as Chartmetric- and Spotify-export tooling often does — /bulk now collapses it to a clean name automatically. Previously a single over-long multi-artist credit (think a big classical ensemble) could exceed the field limit and reject the entire batch; that can’t happen anymore, and the cleaner artist string also matches more tracks. Nothing to change on your side.

GET /lookup now also accepts title as an alias for track — the same alias /bulk items already supported — so a ?title=… query resolves instead of returning a parameter error.

/bulk-csv now returns partial results, like /bulk

POST /bulk-csv no longer fails an entire file when it can’t finish in time. A large or miss-heavy CSV used to hit the 25-second server time-box and come back as a single 503 with nothing enriched. Now it returns a 200 with a partial CSV: every row that resolved is fully enriched, and any rows the deadline cut off come back with found="processing" and empty feature columns — and those rows are not charged. Re-submit just the processing rows to collect them. Three response headers — X-Bulk-Complete, X-Bulk-Rows-Processed and X-Bulk-Rows-Pending — summarise the outcome so you can tell a complete file from a partial one without scanning the rows. This brings /bulk-csv in line with the partial-results behaviour /bulk and /v1/audio-features?ids= already had.

As part of the same change the file is now enriched with bounded parallelism rather than one row at a time, so it’s several times faster and far fewer files run long enough to be split at all. We also fixed a column-alignment edge case: if your CSV has extra columns beyond artist and title and a row omits a trailing value, the appended audio columns now stay correctly under their own headers.

/bulk now accepts ISRC, not just name + artist

Each item in a POST /bulk request can now be identified by ISRC as well as by track + artist — send { "isrc": "USUM71900001" }, or { "isrc", "artist", "title" } for an exact match with a name fallback in one pass (we accept title as an alias for track). Because an ISRC is exact, it resolves the CJK, K-pop and niche tracks whose romaji/native titles slip past a fuzzy name search — ideal if your candidates already carry ISRCs from Chartmetric, Spotify or a label feed. The ISRC you send is echoed back on each result so you can map the batch onto your inputs.

When an item resolves to a real recording we haven’t analysed yet, it’s queued for on-demand ingest straight from the batch (paid plans) — so a single resolution pass warms the catalogue for your next call instead of needing per-track follow-up requests. We also tightened billing: you’re charged per item that returns features or queues an ingest, so an ISRC with no match anywhere is free.

ISRC lookups from your AI assistant

Our Model Context Protocol server now mirrors the new /bulk capability: the batch audio-features tool — get_audio_features_batch on the hosted server, bulk_lookup in the npm package — accepts an isrc on each item as well as track + artist. So a Claude, Cursor or Windsurf workflow resolving a playlist of ISRC-keyed tracks gets exact matches on the first pass, including the CJK/K-pop and niche titles a name search misses. The hosted server at mcp.freqblog.com is already live; npx users get it by updating to [email protected].

Every response tells you what to do next

We swept every status code the API returns and made the few unclear ones plain. A 202 (track queued for on-demand analysis) now carries a Retry-After header and a retry_after_seconds field so your code can pace its re-poll, and it’s explicitly flagged as a success-in-progress, not an error. An out-of-range parameter (e.g. ?bpm=5) returns a one-line “bpm must be at least 20” instead of a raw validation dump. An unsupported upload to /analyze now names the accepted formats (MP3, WAV, OGG, FLAC, AAC/M4A). A genuine server error returns a clear JSON body linking our status page instead of a bare “Internal Server Error”, and brief gateway blips during a deploy return a clean message too. We also corrected the docs: slow /bulk and /v1/audio-features batches return a 200 with partial results (retry to collect the stragglers), not a 503.

On your dashboard: a new “🔔 Ready for you” panel shows the tracks you requested that have just finished analysing — so even with ingest emails off, you can see at a glance what’s ready without waiting on an email.

Account alerts where you'll actually see them

If a renewal payment fails, or your subscription is set to cancel, we email you — but emails get filtered or missed. Your usage dashboard now also shows a clear banner: a red “payment failed — update your card” notice (with a one-click link to your billing portal) while a payment is past due, or an amber “subscription ending” notice with the date if it's scheduled to cancel. Accounts in good standing see nothing — it only appears when there's something to act on.

A 429 now tells you exactly which limit you hit — and how to stay under it

If you fan out a lot of parallel /lookup calls (say, scanning a DJ library), the free tier caps you at 6 requests in flight at once — extra parallel calls come back 429 with Retry-After: 2. That’s deliberate back-pressure so one client can’t starve another, but the old response didn’t make that clear. Now every 429 carries a machine-readable reason (concurrency_cap, monthly_quota_exceeded or daily_fair_use) and a plain-English message: it’s a normal limit, not an outage, and the fix is to pace your fan-out or batch with POST /bulk (up to 50 tracks in one call).

New: every quota-charged response now includes X-Quota-Used, X-Quota-Limit and X-Quota-Reset headers, so you can watch your monthly usage and pace yourself long before you hit the wall — no extra call needed. Free accounts also get a heads-up email as they approach their monthly limit, and the docs now spell out the concurrency limit alongside the per-minute and monthly ones.

Titles like “angel (Prod. by GRAY)” now resolve on the first try

A K-pop or hip-hop track title often carries a production credit(Prod. by GRAY), (Prod. GroovyRoom), (Produced by X). Like a featured-artist credit, that’s metadata on the same recording, not a different track — but the extra words used to push the real match out of reach, so a lookup could come back empty for a track we can actually serve. The resolver now strips a trailing production credit before searching, exactly the way it already handles (feat. …), so those tracks resolve straight away and feed the on-demand ingest cleanly.

It’s deliberately narrow: only a trailing (Prod. …) credit is dropped — distinct recordings like (Remix), (Live) or (Acoustic) and ordinary titles are left untouched. Most visible on K-pop, J-pop and hip-hop catalogues, where the convention is everywhere.

One call for tags — /tag, and every tag carries its provenance

A new GET /tag endpoint returns a compact, ready-to-use tag list for any track — energy, danceability, valence, acousticness, instrumentalness, a mood label and a broad genre tag — resolved by name (track + artist), isrc, mbid, spotify_id or catalog track_id. It’s a tag-shaped projection of the analysis /lookup already returns: no audio upload, no new compute, and it costs the same 1 request as a lookup (charged only on a served result).

The honesty is the feature. Every tag is labelled with how we know it — a confidence and a provenance — so you never have to guess what’s a hard measurement and what’s an estimate:

  • measured (essentia) — the numeric audio features from our own Essentia analysis, e.g. high-energy 1.0, very-danceable, acoustic.
  • derived (valence+energy) — a MIREX-quadrant mood label computed from measured valence and energy, e.g. tense.
  • model-estimated (acousticbrainz) — a mood probability from the AcousticBrainz SVM model, with the raw probability in value.
  • catalog-genre (catalog) — a broad catalogue genre tag, e.g. synthwave.

Numeric tags carry their [0,1] value; label-only tags (the mood category, genre) report value: null. A quick check: /tag?track=Blinding Lights&artist=The Weeknd returns measured high-energy / very-danceable / acoustic tags, a derived mood of tense, and a catalog-genre of synthwave — each clearly marked. The broad, reliable coverage is the measured tags, available for the full analysed catalogue and for anything you look up by name (on-demand on a miss). You can also tag by mbid or isrc against 7.5M+ AcousticBrainz recordings — when you supply the identifier. For the full numeric feature set use /lookup; for the nearest-sounding tracks use /similar.

The Spotify /recommendations and related-artists drop-in — live now

Spotify removed GET /v1/recommendations and GET /v1/artists/{id}/related-artists in November 2024 and finished locking them down in early 2026, leaving every recommendation and discovery feature built on them with nothing to call. They’re the single largest gap left by the audio-features deprecation — and 18 months on, still unfilled. Two new endpoints fill it, on the same auth and monthly-quota model as /lookup (quota charged only on a served result):

  • GET /recommendations?seed_tracks=&limit=20 — the Spotify /v1/recommendations replacement. Pass 1–5 catalog track ids; we blend them into a feature-space centroid, take the nearest tracks across the whole audio-feature index, then re-rank by genre affinity so a sonically-close but cross-genre track doesn’t outrank your same-genre picks. Each result carries the raw audio-feature cosine score (genre affects the order, so the list isn’t strictly score-descending). Optional exclude_seed_artists=true. Charges 2 requests.
  • GET /related-artists?artist=&limit=20 — the Spotify related-artists replacement. There is no editorial artist graph behind Spotify’s version and none behind ours either — we derive one: the seed artist’s track-vector centroid → nearest catalog tracks → aggregated by artist, each scored on its top-3 track similarities (so a prolific back-catalogue can’t dominate) with a same-genre lift and a cross-genre penalty. Returns related artists with a score, match_count and a sample_track_id. Charges 2 requests.

Built on what we already have — our own audio-feature similarity index and our own catalogue genre data, not a third-party listening graph. seed_tracks and sample_track_id are the catalog itunes_track_id values returned by /search or any /lookup response. A quick check: /recommendations?seed_tracks=apple_ad1829eeccb70f9a (Van Halen) returns an all hard-rock set — Alice Cooper, Guns N’ Roses, Aerosmith, David Lee Roth — and /related-artists?artist=Van Halen leads with rock peers like Danger Danger and AC/DC.

Score a transition, find the next track, order a whole crate — Harmonic Set Builder

Three new endpoints turn the catalog’s BPM, Camelot key and energy into set flow — not just raw fields, but how well tracks mix and what order to play them in.

  • GET /transition?from_track_id=&to_track_id= — scores how cleanly two catalog tracks mix, 0–100, blending Camelot-wheel key compatibility, octave-aware BPM proximity (half/double-time counts as a match) and energy smoothness. Returns the component scores and a plain-English reason like 11B→11B same key, 118→117 BPM (-0.29), energy +0.12. Charges 1 request.
  • GET /next-track?seed_track_id=&n=10 — the seed’s sonic neighbours, re-ranked by transition score, each with its score and reason. Optional min_score, exclude_same_artist, bpm_drift, max_key_distance. Charges 3 requests.
  • POST /setlist — hand it a crate of 2–100 track ids and an energy arc (peak_time, warmup, cooldown or flat) and it orders them into a beat-matched set, returning the play order, per-step transition scores and an overall flow_score. Charges 5 requests.

Chain it straight into a mix. Pipe the ordered ids from /setlist into GET /export/rekordbox?track_ids=… and drag the result into Rekordbox, Serato, Traktor or Engine DJ. Track ids are the catalog itunes_track_id values returned by /search or any /lookup.

Also fixed: /radio’s harmonic filter now genuinely respects the Camelot wheel. A latent bug meant it was reading key data from un-enriched rows, so in practice it had been matching on BPM continuity alone — the harmonic-adjacency constraint it advertises is now actually applied.

More robust lookups — boundary tempo searches and anomalous tracks no longer fail

A handful of tracks carry an implausible detected tempo — a spoken-word intro the analyser clocked at 738 BPM, or a beatless ambient piece at 0 BPM — or an out-of-range feature value from a fallback data source. Looking one of those up, or running a tempo search right at the edge of the range (e.g. GET /bpm?bpm=20), could previously return a 500. These now degrade gracefully: tempos are bounded into the valid 20–300 BPM range and any anomalous audio-feature value is reported as null instead of failing the request.

The catalog keeps growing — 337k+ tracks across 1192+ genres

The pre-analysed catalog has passed 337,000 tracks spanning 1192+ genres, every one carrying the full 38-field audio profile (BPM, key / Camelot, energy, valence, danceability, mood and more). It grows daily from chart and catalogue ingestion, and on top of that any track you look up that we don’t hold yet is fetched and analysed on demand — so a name lookup (GET /lookup?track=&artist=) returns even brand-new or long-tail releases shortly after the first request.

Coverage is genuinely multi-language — J-Pop, K-Pop, Mandopop, Cantopop, Bollywood, Afrobeats, Amapiano, MPB, Sertanejo, Reggaeton, Arabic and Turkish pop, French chanson and more — not just the Anglo-American chart core. The live figures on the banner above and on the homepage update automatically as the catalog expands.

Subscribe and call the Music Metadata API straight from RapidAPI

The API is now listed on RapidAPI. If your team already manages its APIs there, you can subscribe, get a key, and start calling the same endpoints — /lookup, /bulk, /v1/audio-features and the rest — through RapidAPI’s gateway, billed on your RapidAPI account. Same catalog, same fields, same name-based lookups (no Spotify ID required).

Prefer to sign up with us directly? Nothing changes — the free tier and direct API keys work exactly as before. RapidAPI is simply a second way to onboard for teams that standardise on it.

A paid burst of /lookup calls is queued, not 429’d

If your app fires a burst of concurrent /lookup requests, paid plans now briefly queue the overflow until a slot frees, instead of rejecting it with a 429. Each key still has a fixed number of in-flight lookups it can run at once (that’s what keeps one customer’s burst from slowing everyone else), but on paid plans the requests over that line now wait their turn (up to a few seconds) and get served rather than bouncing. Only a sustained flood beyond the short queue still returns a self-healing 429 with Retry-After.

Net effect: a tight loop or a parallel playlist scan that used to see scattered 429s now mostly just completes — a little slower under load, but served. (For large batches, /bulk and /v1/audio-features?ids= remain the most efficient path.) Free-tier behaviour is unchanged.

More per-minute headroom on paid plans, and headers that let you pace

The per-minute rate limit on the upload endpoints (/analyze and /identify) is now more generous on paid plans: Hobbyist goes from 120 to 300 req/min and Starter from 300 to 600 req/min (Free stays 20, Pro/Enterprise stay 600). Your lookup endpoints (/lookup, /bpm, /key, /bulk, /v1/audio-features) remain quota-only — they aren’t per-minute rate-limited.

Those endpoints now return standard RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers (the IETF draft format) on every response, so a well-behaved client can see exactly how much budget is left and slow down before hitting a 429 — which still carries a Retry-After. If you’re firing many individual calls in a tight loop, remember /bulk (50 tracks/request) and /v1/audio-features?ids= (100/request) replace dozens of round-trips. Need a bespoke limit for a high-throughput integration? Just reply — we can lift it per key.

/v1/audio-features?ids= no longer bills unmapped IDs

Migrating from Spotify by ID? You won’t pay for the misses. A raw Spotify ID only resolves for the minority of tracks we’ve already mapped to one, so a batch of raw Spotify IDs comes back mostly null. Those null slots used to each cost a quota request — even though the same misses are free on the single-ID route, which returns a 404 at no charge.

Now the batch endpoint charges only for IDs that return data. Unmapped IDs (and any not resolved before the time-box) come back null for free, exactly like the single-ID route — so a 100-ID batch where 5 are mapped bills 5, not 100. For full coverage, key your lookups by name (/lookup?track=…&artist=…) or ISRC.

International lookups survive a common client encoding bug

If your code double-encodes a non-ASCII title — a very common UTF-8 mistake that turns 花束 into è… or Freakin’ Out into Freakinâ Out — we used to search the garbled text and return “not found” for a track we actually have. Japanese, Korean, Chinese and accented/curly-quote titles were the usual casualties.

We now auto-repair that encoding on the way in, so the lookup matches the real track. Correctly-encoded titles — including legitimate accents and umlauts (Beyoncé, Mötley Crüe) and clean CJK — are never touched. Nothing to change on your end; it just stops silently missing.

/bulk and /v1/audio-features?ids= now hand back what completed in time

A large batch of less-common tracks used to fail wholesale. If a batch couldn’t finish inside the server time-box it returned a single error and you got nothing back — even for the tracks that had already resolved. For playlists full of long-tail or not-yet-ingested tracks, that meant retrying the whole thing.

Now you get partial results. Tracks that resolved in time come back with their features; the rest are marked still-processing (backfill_status: “processing” on /bulk, or null on /v1/audio-features?ids=) and keep ingesting in the background — just re-request them shortly to collect them. You’re only charged for the lookups that actually resolved. Tip: keeping batches to roughly 15–25 tracks still completes fastest.

How to get full coverage from the Spotify audio_features drop-in

The /v1/audio-features drop-in resolves tracks we’ve already mapped to a Spotify ID — a minority of the catalog. It’s a shape-compatible replacement for Spotify’s deprecated audio_features, not a universal Spotify-ID reverse lookup, so a raw Spotify ID we haven’t mapped yet can come back empty.

For full coverage, identify tracks by name or ISRC. A name lookup — GET /lookup?track=&artist= — searches the entire catalog and queues an on-demand fetch and analysis on a miss, so even tracks we don’t hold yet get ingested and returned shortly. If you already have the ISRC (e.g. from your own Spotify integration’s external_ids.isrc), pass it to GET /v1/audio-features/<ISRC> or GET /lookup?isrc=<ISRC>. We’ve updated the docs and earlier changelog notes to steer callers to the path that actually returns data.

“feat.” credits and remix versions now match first time

When a release lists a featured artist inside the title — e.g. Touch Me (feat. Cassandra) [Radio Edit] — a lookup for the clean name (Touch Me (Radio Edit)) now matches it on the first request, even if you list the featured act alongside the main artist or in a different order (Cassandra & Rui Da Silva resolves the same as Rui Da Silva). This is common in dance / house catalogues, and those tracks used to come back empty.

Remixes, radio edits and extended mixes stay distinct. We only fold the featured-artist credit — we never merge two different versions of a track, so you always get the right recording’s BPM, key and energy. We also fixed a case where a small number of lookups could keep returning “still working on it” instead of resolving.

Look up Japanese & Korean tracks by their native — or romanised — name

City Pop, J-pop and K-pop now resolve far more reliably. You can type an artist in romaji (Miki Matsubara, Mariya Takeuchi) or native script (松原みき, 竹内まりや, 방탄소년단) — we now match across scripts, spacing and transliteration, so the exact track resolves either way instead of coming back empty.

Our backfill now searches the Japanese and Korean stores for tracks typed in those scripts, reaching catalogue that the English-store search never surfaced (K-pop and a large slice of the Japanese back catalogue). We’ve also seeded the City Pop / 80s–90s J-pop canon directly. Tracks genuinely sold nowhere (karaoke-only or unreleased cuts) still come back as a miss — we’d rather return nothing than the wrong recording.

See — and pay — in your own currency

Every price on the site now displays in your local currency. On your first visit we ask which currency you'd like (and take a best guess from your browser locale), then convert every figure — plans, per-1,000 rates, even the comparison table — into it, using live exchange rates. Switch any time with the selector at the bottom-left; your choice is remembered. 45+ currencies are supported.

And it isn't just for display: at checkout you're charged in your local currency too, so the price you saw is the price you pay — no surprise foreign-exchange markup from your own bank. Pricing on FreqBlog stays simple and honest, wherever you are.

What FreqBlog is, stated plainly — plus priority backfill on Enterprise

The site now leads with what actually sets the API apart for developers: you look tracks up by name and artist (or by ISRC, MusicBrainz Recording ID, or Spotify ID) — no Spotify track ID and no audio upload required — against a catalog that is actively maintained and backfilled, not a frozen one-time dataset. Same field names and JSON shape as Spotify’s deprecated audio_features, computed by our own open-source analysis.

Enterprise now spells out priority backfill. On the Enterprise plan, tracks you request that aren’t in the catalog yet jump the analysis queue ahead of free- and lower-tier traffic, so your misses come back fastest. Custom volume, an SLA on request, and a dedicated support channel round out the tier — talk to us.

/bulk-csv billing aligned with its docs; calculator fix above 750k requests

POST /bulk-csv now charges what its documentation always said it would: one quota request per data row. Until now a quirk meant any CSV — even 500 rows — was billed as a single request. Rows with a blank artist or title aren’t looked up and aren’t charged. Quota headroom for the whole file is checked up front, so a file larger than your remaining quota returns a clear 429 before any work starts rather than failing midway.

And the same fairness rule as /bulk now applies: if a miss-heavy file runs past the server time-box it returns a clean 503 (“split the file and retry”) and no quota is charged — previously a too-slow file could time out at the gateway while still being processed (and billed) server-side.

Pricing-calculator correction: for volumes above the Professional tier’s 750,000 requests/month, the homepage calculator previously displayed a “Pro + overage” price — but we don’t sell metered overage (over-quota requests return 429, and our pricing promise is no per-request billing surprises). The calculator now correctly shows those volumes as Enterprise — custom pricing. If you need more than 750k requests/month, talk to us.

Better matching of remastered / mono / stereo / original-mix track versions

A version suffix no longer costs you a match. Looking up a title like Whole Lotta Love - 1990 Remaster, Dreams (Mono) or Substitution (Original Mix) now resolves to the recording and returns full audio features — where before the exact version string could come back as a miss. A remaster / mono / stereo / original-mix is the same performance, so its BPM, key and energy match the original.

Genuinely different recordings are still treated as their own tracks. Remixes, and acoustic, live, instrumental or extended versions have different audio features, so they’re matched on their own — never collapsed onto the original. You always get the numbers for the exact recording you asked for.

Batch lookups (/bulk and /v1/audio-features): steadier under load, oversized batches fail fast (and free)

The 50-tracks-per-request limit is unchanged. POST /bulk still takes up to 50 tracks in one round-trip, and a normal playlist of catalogued tracks still returns in well under a second.

One big batch can no longer slow everyone else down. A /bulk request's lookups are now resolved in parallel up to a bounded limit, so a large, lookup-heavy batch can't monopolise the service and stall your (or anyone else's) single /lookup calls. Throughput on ordinary playlists is unchanged.

Oversized, obscure batches now fail cleanly instead of hanging. A batch of many tracks that aren't in the catalogue yet can take a while to resolve. If one runs past our server time-box it now returns a clear 503 (“split into ≤15 tracks and retry”) within a few seconds, instead of timing out at the gateway after a minute — and a cancelled batch isn't charged against your quota. For deep, rare-catalogue migrations, ~15 tracks per batch is the sweet spot.

One more guard: a small number of batch requests per key run at once; fire many in parallel and the extras get a 429 with Retry-After — pace them and they’ll all go through.

The same protections cover the Spotify-shape batch endpoint, too. POST /v1/audio-features?ids= (up to 100 IDs) now shares the identical guards — bounded parallelism, the same per-key concurrency cap, a clean 503 on an oversized batch of uncatalogued IDs (≤25 per call is the sweet spot there), and no charge for a cancelled batch. So whichever batch route you use, a large miss-heavy job fails fast and free rather than hanging.

Faster, more reliable backfill — and fewer emails

Paid plans now get priority backfill processing. When you look up a track that isn't in the catalogue yet, your on-demand analysis now jumps ahead of free-tier traffic in the queue — so paid lookups resolve first under load.

Transient failures now retry automatically. If a track briefly fails to resolve during a busy burst (a momentary upstream hiccup), we re-attempt it in the background instead of giving up — so far fewer lookups get stuck as "no preview" when the track is actually available. Re-query and it'll be there.

Fewer notification emails. Backfill updates now roll into a daily digest for high-volume use, so running a large batch through the API no longer floods your inbox.

Hit a free-tier limit? Your key keeps working.

The free tier has a daily fair-use limit on fetching tracks that aren't in the catalogue yet (on-demand analysis). Previously, exceeding it could deactivate your key. That was too blunt — genuine evaluators occasionally tripped it and got locked out. It's now a self-healing daily pause: you get a 429 with a Retry-After header, your key stays active, catalogue lookups keep working, and the limit resets automatically at 00:00 UTC. Upgrade to remove the daily limit entirely.

We also made the limits easier to see before you hit them: a new Limits & rate limits section on the home page (quotas, per-minute rate limits, and what each response code means), and a clearer "plan limits" panel plus split rate-limit/quota labels on your usage dashboard.

One-click MCP connector — nothing to install

There's now a hosted Model Context Protocol server at https://mcp.freqblog.com/mcp. In Claude, open Settings → Connectors → Add custom connector, paste that URL, and your assistant can look up BPM, musical key (name + Camelot + Open Key), energy, danceability, mood, genre and more as first-class tools — no npm install and no API key. It works the same in Cursor ("url") and Windsurf ("serverUrl").

The hosted connector speaks the current Streamable-HTTP MCP transport and exposes six core tools: get_audio_features (by track + artist, ISRC, MusicBrainz ID or Spotify ID), get_audio_features_batch (up to 50 tracks), search_catalog, find_tracks_by_bpm, find_tracks_by_key and find_compatible_keys (Camelot-wheel harmonic mixing). For a local server with your own key and the full 17-tool set — recommendations, harmonic radio, DJ-format export, country charts, lyrics and waveforms — the music-metadata-mcp npm package is still there. See the setup section on the home page.

Look up a track by its MusicBrainz Recording ID

/lookup now takes a fourth key alongside ?track=, ?isrc= and ?spotify_id=: ?mbid=<uuid>. Pass a MusicBrainz recording MBID and you get the same full feature set back. It's the precise key for the cases the others can't cover well — recordings from before 1986 that were never assigned an ISRC, and titles where a name search is ambiguous (live takes, re-recordings, alternate edits). If you already resolve releases through MusicBrainz — for example, enriching a Discogs collection — you land on an MBID naturally.

Features are read straight from AcousticBrainz for that exact recording — the authoritative MBID→features path — with title and artist resolved from MusicBrainz. Because a recording ID maps one-to-one to a specific take, it's the most exact way to ask for a particular master rather than “whatever matches this name.” When AcousticBrainz holds no analysis for the recording, the call returns 404 together with the recording's identity, so you can fall back to ?isrc= or ?track=&artist= deliberately instead of silently matching the wrong cut.

Tracks with a known ISRC no longer return isrc: null

The response surfaced an ISRC only when MusicBrainz happened to have one — but the large majority of our ISRC coverage comes from other sources. So a track we did hold an ISRC for could still come back with isrc: null. The lookup, /bpm, /key, /bulk and CSV paths now return the ISRC stored on the catalog row, falling back to the MusicBrainz value only when that's the only one available. This is purely additive — tracks that already returned an ISRC are unchanged; tracks that wrongly returned null now carry the code.

The same release also corrects a set of genre values that had been stored in a non-English localisation (for example, a Japanese rendering of “Rock”) when a track resolved through a regional storefront. Those now return the canonical English genre, and the ingest path no longer stores a localized genre going forward.

Artist matching now bridges renamed and re-credited artists instead of queuing a track you already have

Lookups match on the artist name as well as the title, and the matcher used to be one-directional — it handled a typed name that was shorter than the stored one (a featured-artist suffix), but not one that was longer. So ?track=Gin and Juice&artist=Snoop Doggy Dogg would miss the catalog's canonical Snoop Dogg recording (his earlier credit isn't a clean substring of the later one) and fall through to an on-demand ingest — returning 202 for a track that was already analysed.

Two additions fix this: matching on the set of name tokens in either direction (so an expanded or abbreviated credit still resolves), and a curated stage-name alias list for renames that share no words at all — YeKanye West, Yasiin BeyMos Def, YusufCat Stevens, and more. A query using any name in a group resolves to whichever form the catalog stores. Both checks are anchored on an exact title match, so they only ever add resolutions — nothing that matched before changes.

A track with no preview anywhere now returns a 404 you can back off on — not an endless 202

When a ?track=&artist= lookup misses, we queue an on-demand ingest and return 202. But if that ingest has already run and the track has no 30-second preview in any source (iTunes or Deezer), re-requesting it now returns 404 with a machine-readable backoff: a standard Retry-After header (in seconds) plus terminal: true and retry_after_days in the body. Automated clients can stop re-polling a verdict that won't change yet instead of hammering the same dead track.

The verdict is cached for 7 days — long enough to avoid wasted retries, short enough that a newly-added preview is picked up on the next attempt — which is exactly what Retry-After advertises. The detail message is unchanged (still a plain string), so existing error handling keeps working; the new fields are purely additive. Tracks that genuinely have no commercial preview (SoundCloud/Bandcamp-only releases) can still be analysed by uploading the audio to POST /analyze.

Opt-in calibrated mode: makes the loudness number directly comparable to Spotify's cached numbers

Pass ?calibrate=spotify to /v1/audio-features/{id} or /v1/audio-features?ids=… and we apply a linear regression to loudness that maps our values onto Spotify's reporting scale. Default (omit) returns raw values — existing integrations are unaffected.

Coefficients (v1): loudness_calibrated = 0.557 × loudness_freqblog + 0.960. Fitted on a 5,724-track overlap between our catalog and the public maharshipandya/spotify-tracks-dataset (BSD-licensed, 114k tracks with Spotify's pre-deprecation audio_features). R² = 0.43, RMSE 3.0 dB vs raw 8.5 dB — 64% closer to Spotify's number after calibration.

Worked example: Adele "Hello" returns raw loudness = −10.96; calibrated returns −5.14. Cached Spotify value for the same recording: −6.13. Not identical (same physical quantity, different measurement framework) but the cached-Spotify-blending use case becomes practical.

Other audio-feature fields don't ship calibration in v1. Per-field R² on the same 5,724-track sample: energy 0.28, danceability 0.18, valence 0.005, acousticness 0.07, instrumentalness 0.003, liveness 0.004, speechiness 0.014. The heuristic-vs-Spotify-ML methodology gap is too wide to fit a regression that wouldn't be dishonest. tempo already matches Spotify well on most tracks (69% within 1 BPM); the half/double-time disagreement (~11% of tracks) is what bpm_alt solves on the standard /lookup endpoint — arguably better than Spotify's behaviour on those tracks. See the field-by-field threshold guide for the full breakdown.

v2 (planned): refining the regression with additional labelled datasets and exploring per-genre coefficients. If you're working with cached Spotify data and want to share your overlap, [email protected].

Honest framing for the Spotify-shape API: shape, not values

The /v1/audio-features/{id} and /v1/audio-features?ids=… routes have always returned the exact same field names and JSON structure as Spotify’s deprecated audio_features endpoint — you can swap api.spotify.com for api.freqblog.com and the path + response keys are byte-identical. But the numeric values are computed by this pipeline’s open-source analysis (Essentia for bpm/key/meter, librosa for loudness, librosa-derived spectral features fed into bespoke heuristics for valence/energy/danceability), not by Spotify’s proprietary ML models. So if you’re blending against cached Spotify audio_features numbers, you’ll need to recalibrate per field.

Updated copy across the homepage hero, /compare page, OpenAPI tag + endpoint descriptions, and the feature_source field-table footnote to say “Spotify-shape compatible” rather than the previous “drop-in” framing, which read as value-compatible. The Field-by-Field Threshold Guide already covers the per-field recalibration math.

Coming next: a ?calibrate=spotify opt-in mode that applies a regression layer mapping our values onto Spotify’s scale — in development with calibration constants from a customer-contributed overlap audit.

Find tracks by BPM with /bpm?bpm=128

The /bpm endpoint takes a target tempo and returns catalog tracks within ±tolerance of it. That target is now passed as bpm — e.g. /bpm?bpm=128&tolerance=2&limit=10. The endpoint previously only accepted the tempo as q, which isn't an obvious name for an endpoint called /bpm; bpm is now the documented parameter, and what the API docs and examples show.

Integration note: nothing breaks — ?q= still works as a hidden alias, so existing integrations need no change. New code should use ?bpm=. A /bpm call with no target now returns a 422 that names ?bpm= directly, instead of a generic “field required”.

ISRC lookups now resolve through Deezer — and a resolvable miss queues an ingest instead of returning 404

Looking a track up by ?isrc= previously resolved through the catalog and then MusicBrainz only. MusicBrainz's ISRC index is patchy — plenty of valid, real ISRCs (independent and regional releases especially) simply aren't registered there — so those lookups returned 404 even when the recording genuinely exists.

/lookup?isrc= now adds Deezer to the resolution chain (catalog → MusicBrainz → Deezer), and Deezer's ISRC coverage is far broader. The practical effect: an ISRC we can resolve to a recording but haven't analysed yet no longer dead-ends — it returns 202 and queues an on-demand ingest, exactly as a ?track=&artist= miss already does. The track is typically analysed and queryable within a few minutes.

Integration note: if your code treats a 404 from /lookup?isrc= as “this ISRC will never resolve”, the response on a resolvable-but-not-yet-analysed ISRC is now 202 (queued — retry shortly). A 404 now means the ISRC resolved nowhere: not the catalog, not MusicBrainz, not Deezer. The Spotify-compatible GET /v1/audio-features/{id} endpoint is unchanged — it still returns 404 on a miss, as it has no asynchronous-ingest mode.

~1,700 catalog tracks now return canonical genres on /lookup

Two coupled changes landed together to improve the accuracy of the genre field. Both touch which value is returned, not the field's shape or presence — existing integrations do not need any code changes.

Classic soul/funk corrections (574 tracks). Marvin Gaye, Stevie Wonder, James Brown, Earth Wind & Fire, Aretha Franklin, Funkadelic, and 19 other classic soul/funk artists had a meaningful slice of their catalog upstream-tagged as electronic or otherwise wrong in our metadata sources. /lookup?track=Sexual+Healing&artist=Marvin+Gaye previously returned genre: "electronic"; it now returns genre: "soul". A new artist-level override table enforces this at end-of-cron and at API startup, so subsequent metadata-pipeline refreshes can't drift the values back.

Chart-derived genre flow (1,157 chart rows + ongoing cron output). Tracks ingested via our Apple and Deezer genre-targeted chart feeds now carry their chart-derived genre — a track surfaced from the US Dance chart lands as genre: "dance", US Electronic as "electronic", and US Ambient / Downtempo / Electronica / IDM / Electro similarly. Previously these tracks had genre: null because the catalog only resolved genre via a MusicBrainz round-trip, which most chart-discovery rows skip. Largest immediate impact: the genre='dance' bucket went from 12 rows to 344, and will continue growing on every chart-cron cycle. Regional charts (Top Global, Brazilian, Indian, German, etc.) are deliberately not mapped — tagging a Brazilian pop chart as "brazilian" would mis-classify mainstream pop hits as a regional genre and break customer genre filters.

The bpm_alt sibling field now only fires when it should

The bpm_alt field on /lookup responses is meant to suggest a half-time-corrected alternative when Essentia's rhythm extractor locks onto half the actual beat grid (a common failure mode on upbeat synthwave / drum & bass tracks where the analyser reports ~85 BPM for a real ~170 BPM track). Until today the heuristic could over-fire on tracks that are genuinely slow — a Johnny Cash ballad at 91 BPM would report bpm: 90.65, bpm_alt: 181.3, which was nonsense. Same for Radiohead's "Creep" (92 BPM correctly slow), OutKast's "Ms. Jackson" (95 BPM correctly slow), Lorde's "Royals", Eminem's "Lose Yourself", and several others.

The new rule uses the genre field as a Bayesian prior. Tracks tagged in a slow-genre set (country, folk, ballad, hip-hop, alt-rock, jazz, blues, soul, R&B, classical, acoustic, indie, soundtrack, ambient) are never flipped, regardless of how dance-able the analyser thinks they are. Tracks tagged in a fast-genre set (dance, electronic, house, techno, synthwave, trance, drum & bass, EDM, trap, reggaeton, etc.) fire on moderate evidence. Tracks with no genre information require stronger evidence (high energy + danceability) before a flip is proposed. True positives are preserved — The Weeknd's "Blinding Lights" (synthwave, 85 BPM analysed) still flips to 170.78, Luude's "Big City Life" (D&B, 87 BPM analysed) still flips to 173.76.

Field shape unchanged: bpm_alt is still float | null. Existing integration code does not need any changes — if your code reads bpm_alt when present and falls back to bpm, that path is now safer. If you had been ignoring bpm_alt because of these false positives, it's a good time to re-enable it.

Intelligent Backfill now drains queued tracks ~5× faster under load

Customers running large bulk-lookup sessions — say a 1000-track DJ library coverage check or a playlist-import pre-fill — were seeing queued /lookup misses resolve more slowly than expected when the queue depth crossed the worker's high-load threshold. The cause was a deliberate-but-misjudged "thundering herd" guard that increased sleep cadence when the queue grew, exactly when faster drain was needed.

The worker now halves its sleep interval under high queue load (from the default 45 seconds down to ~22 seconds) and processes 4 concurrent jobs per cycle instead of 2. The rate-limit defenses against external APIs (iTunes, Last.fm, MusicBrainz) live in the API-call layer, not in this sleep loop, so the acceleration is safe — we're using the per-service token-bucket budgets we already had, just consuming them more eagerly when there's a backlog to clear.

Measured end-to-end on a real customer's 965-track bulk session today: total drain time dropped from a projected ~12h 41m to 5h 17m actual (peak rate 412 jobs/h vs ~76 jobs/h pre-fix — a 5.4× improvement). The "Intelligent Backfill — 30s–2min" promise holds further into scaled traffic. No API surface changes; this is purely faster TTR on existing endpoints.

Three fixes that turn more /lookup 202s into 200s

Three independent match-quality improvements landed together. Each one closes a specific class of query where the catalog already had the track but /lookup previously couldn't see it — so the request returned 202 Queued for ingest instead of 200 with the cached features.

  • Comma-list artists. Beatport-style multi-artist queries (e.g. artist=Basement Jaxx, Steve Angello for a track stored under just Basement Jaxx) now resolve. The matcher tries the first comma token as a fallback when the full string doesn't match, so multi-credit input is more specific than the stored form rather than failing.
  • Canonical wins over derivatives. When a fuzzy title + artist query has more than one substring match (e.g. the canonical single and a DJ-mix album entry that contains the title), the more-popular row now wins. Previously the result was arbitrary.
  • Learned aliases. When iTunes resolves a query to a track that is already in the catalog — common when searching in a different script (kana for an English-stored Japanese pop track, hangul for a romanised K-pop title) or with a longer artist credit than the stored form — we now record the mapping. The next identical query lands on a 200 directly, no on-demand cycle.

No request or response schemas changed; the existing fields populate the same way. Integration code does not need any changes — the win is a higher catalog-hit rate on traffic that previously fell through to on-demand.

More /lookup misses recover via the Deezer fallback

When a /lookup miss triggers on-demand ingest and iTunes has the track in its catalog but doesn't return a streaming preview for the recording (region-locked, deprecated CDN, or a release that simply lacks one), we now attempt to recover the preview from Deezer as a Tier-3 fallback. If Deezer has the track, we analyse the recovered preview and add the row to the catalog the same way an iTunes-resolved track is added.

Day-to-day this means a portion of queries that previously stayed permanently no_preview (typically older tracks, niche dance/electronic releases, and some international catalogues) now complete cleanly. No request changes; responses still include the same fields. Tracks recovered this way appear with source: on_demand in the response — identical to iTunes-recovered tracks — so downstream code does not need to special-case the source.

The fallback is rate-limited and budget-capped at the source level, and the per-host probe runs every 15 minutes with an automatic kill-switch on any sign of upstream degradation. If Deezer ever 429s or 403s us, we fall back to no_preview the same way we did before this change.

Turn ingest & backfill emails on/off from your dashboard — new /me/preferences endpoints

You can now silence the per-track ingest-completion and backfill emails without contacting support. A new Notifications section on your dashboard shows the current state and toggles it with one click. Programmatic access via two new endpoints:

  • GET /me/preferences{"ingest_emails_enabled": true|false}
  • POST /me/preferences/ingest_emails?enabled=true|false → same shape, persisted

What this toggle controls: the per-track email fired when a /lookup catalog miss triggers an on-demand ingest, the per-track failure email when an ingest cannot complete, and the field-backfill completion email for catalog tracks. The Recent Activity panel on the same dashboard is the in-app channel when emails are off — every job (queued, processing, completed, failed) still shows up there in real time, so you never lose visibility.

What it does not touch (these continue regardless): your initial welcome email and API key delivery, billing receipts, security-sensitive notices (e.g. key rotation), and quota-warning emails at 80% / 95%. The flag is per-account (applies to every active key sharing your sign-up email).

The /lookup 202 response message is now preference-aware too — when emails are off, it points you to the dashboard instead of promising an email that won't arrive.

Response Fields card on the dashboard now covers all 38 fields

Two small documentation drifts on the customer dashboard at api.freqblog.com/dashboard:

  • New Backfill group in the Response Fields card, surfacing backfill_status and backfill_notification_id. Both are returned on every /lookup response and are what you poll to track an on-demand ingest from queued → processing → completed — previously they were emitted by the API but absent from the dashboard's field-list reference. Audio Features moved to its own full-width row so all 27 chips render comfortably.
  • key_int — the Spotify-style pitch-class integer shipped on May 11 — now has its own entry in the fields-table on the homepage alongside key, mode, and key_confidence. Field-count site-wide was already 38; this closes the table-vs-count drift.

No request/response schemas have changed — existing integrations are unaffected.

Batch drop-in: GET /v1/audio-features?ids=id1,id2,…

Spotify's "Get Several Tracks' Audio Features" now has a drop-in too. Up to 100 comma-separated identifiers per request — mixed Spotify track IDs and ISRCs are fine. Response is {"audio_features": […]} with the array length and order matching the input; entries are null for IDs that don't resolve (matching Spotify's behaviour, so a single bad ID never fails the whole batch).

  • Each entry is auto-detected per the single-ID route — bare Spotify track IDs, spotify:track:… URIs, open.spotify.com/track/… URLs, or ISRCs (hyphens optional).
  • Quota: each ID counts as one quota request (a 100-ID batch uses 100 tokens, same as 100 single calls). Spotify billed a 100-ID batch as 1 call — we don't, because each lookup runs its own catalog query (and possibly a MusicBrainz roundtrip on an ISRC miss). Honest pricing for our cost; at Spotify-scale traffic, see pricing for higher tiers.
  • Errors: 400 for an empty or over-100 ids, 422 when ids= is missing entirely, 429 on insufficient quota for the batch size. Standard {"detail": …} shape.

Drop-in replacement: GET /v1/audio-features/{id}

Migrating off Spotify's deprecated GET /v1/audio-features/{id}? Change the host — api.spotify.comapi.freqblog.com — and your existing call works unchanged. Same path, same response field names and shapes: acousticness, danceability, energy, instrumentalness, key (0–11, -1 if undetected), liveness, loudness (dB), mode (0/1), speechiness, tempo (BPM), time_signature, valence, plus id / uri / track_href / analysis_url / duration_ms / type.

  • {id} auto-detects: a 22-char base-62 string is a Spotify track ID (also accepts spotify:track:… URIs and open.spotify.com/track/… URLs); a 12-char ISRC (e.g. USUM71900001, hyphens optional) is looked up by ISRC. The Spotify-ID path resolves recordings we've already mapped to a Spotify ID (a minority of the catalog) — it isn't a universal Spotify-ID reverse lookup. For full coverage, identify the track by name (GET /lookup?track=&artist=, which fetches and analyses on a miss) or by ISRC. If your own Spotify integration already gives you the ISRC (external_ids.isrc), call this endpoint with that ISRC.
  • New key_int field on /lookup and /analyze — the Spotify-style pitch-class integer (0 = C … 11 = B; null if undetected) alongside the existing human-readable key like "C#-Minor".
  • The numbers are FreqBlog's own analysis — directionally compatible with Spotify's, not numerically identical (a threshold-retuning guide is coming). analysis_url is always null (no per-segment-analysis analog). Errors use the standard {"detail": …} shape. Counts as one quota request. For batch, use POST /bulk (a Spotify-shaped ?ids= batch form is on the roadmap).

/lookup now accepts ?isrc= and ?spotify_id=

Migrating off Spotify's deprecated audio_features endpoint? You no longer need the track title — /lookup now takes any one of three identifiers:

  • ?isrc=USUM71900001 — looks up the catalog by ISRC (case- and hyphen-insensitive), then resolves the recording via MusicBrainz on a miss. Returns 404 when no track is found for that ISRC.
  • ?spotify_id=… — pass a Spotify track ID (also accepts a spotify:track:… URI or an open.spotify.com/track/… URL). Resolves only recordings we've already mapped to a Spotify ID (a minority of the catalog) — it isn't a universal Spotify-ID reverse lookup. For full coverage, look the track up by name (?track=&artist=, which fetches and analyses on a miss) or by ISRC — and if your own Spotify integration already gives you the ISRC (external_ids.isrc), use ?isrc= instead.
  • ?track=…&artist=… — unchanged; still queues an on-demand ingest (HTTP 202) when the track isn't found in any source.

Supply exactly one of the three. Existing ?track=&artist= calls are completely unaffected.

OpenAPI spec now exposes info.contact, info.license, and servers[]

If you generate clients from api.freqblog.com/openapi.json with OpenAPI Generator, Postman, Stoplight, or similar tooling, you'll now get the contact email, license URL, and server URL embedded in the generated SDK header / docs — previously those three fields were being silently stripped by an internal override even though the underlying app metadata was set correctly.

  • info.contact{name: "FreqBlog", url: "https://freqblog.com", email: "[email protected]"}
  • info.license{name: "Commercial — see freqblog.com/terms.html", url: "https://freqblog.com/terms.html"}
  • servers[][{url: "https://api.freqblog.com", description: "Production"}]

No code changes needed on your side — just regenerate your SDK and the metadata appears. Endpoint paths, request/response schemas, and the X-Api-Key security scheme are unchanged.

Dark theme, /synthetic/{layer}/{id} alias, legacy audit script

Three small clean-ups that don't change the API surface but remove rough edges.

  • Dark theme toggle on the marketing page — button in the nav (◐ / ☀), CSS-variable swap via body.theme-dark, persistence via localStorage, default driven by prefers-color-scheme. No flash of unthemed content thanks to a one-line early-apply script in <head>.
  • New GET /synthetic/{layer}/{id}/{sub_path} alias — some HTTP libraries and proxies misbehave on the : in mb:UUID path segments. The alias 307-redirects to the canonical /track/{layer}:{id}/{sub_path} URL. Use /synthetic/mb/<uuid>/embedding when the colon-form trips your stack.
  • Legacy data audit script (scripts/audit_legacy_rows.py) — verifies the 1,965 + 1,181 legacy_deezer_chart / legacy_billboard_history rows still have full analysis backing. Decision: keep them as long as coverage stays at 100% (popular tracks like Blinding Lights live there). Run with --purge-orphans to clean up if any row ever loses its analysis_cache entry.

Cost calculator, click-to-search genres, incident timeline, day-3 drip

Four tweaks to lift the funnel. None of them change the API; all of them remove friction or surface value.

  • Live cost calculator in the pricing section — drag a slider for monthly volume and see your bill on FreqBlog vs AudD vs MusicAPI.com vs Cyanite, in real time and in any of the 8 supported currencies. Closes deals on volume buyers.
  • Click-to-search genre tags — the 100+ genre chips on the homepage are now interactive. Click any tag to run a live /search against the catalog and see results inline.
  • Incident timeline on /status — new section below the live monitors renders incidents from /incidents.json. Pre-seeded empty — entries get appended manually during downtime. Adds enterprise credibility on procurement reviews.
  • Day-3 drip email — new scripts/day3_drip_email.py, cron-driven (hourly during UK working hours, weekdays only). Catches free-tier signups who haven't called the API after 3 days and sends one polite "here's a working code snippet" follow-up. Idempotent via a marker stored on the api_keys row, hard-capped at 10 emails per run, scoped to real-looking emails only.

Live demo widget, 3 SEO articles, refreshed OG image

Cutting trial friction to zero and seeding distribution for the long tail of high-intent search.

[email protected] — 17 tools live on npm + auto-generated SDK recipes

Published v2.0.0 of the MCP server — 13 new tools wrapping every customer-facing endpoint we've shipped this week. AI assistants (Claude Desktop, Cursor, Windsurf) can now build harmonic playlists, identify recordings, render waveforms and export DJ files without writing any HTTP code.

  • New tools (13): search_tracks, find_artist_tracks, list_genres, tracks_in_genre, harmonic_keys, track_embedding, find_similar_tracks, country_chart, build_radio_playlist, export_playlist, track_artwork_url, track_lyrics, track_waveform_svg.
  • Existing tools retained: lookup_track, find_tracks_by_bpm, find_tracks_by_key, bulk_lookup.
  • No breaking changes — drop-in upgrade from 1.x. npx music-metadata-mcp picks up the new version on next launch.

Also added an "Auto-generate clients" section to /sdk/ with one-line recipes for Go, Ruby, PHP, Rust, Swift and Kotlin via openapi-generator-cli. Pulls straight from our live OpenAPI spec, which is auto-generated from Pydantic models — so generated clients always track the API exactly.

Three new /track/<id>/… endpoints — lyrics, waveform, extended classifications

Filling three known gaps without adding new dependencies or background processes.

  • GET /track/<id>/lyrics — synced + plain lyrics via the open LRClib dataset. Synced lyrics arrive pre-parsed as [{ms, text}, …] so no LRC parsing client-side. Cached 30 days per (track, artist), misses too.
  • GET /track/<id>/waveform.svg?w=600&h=80 — SVG waveform of the track's 30-second iTunes preview. 120 RMS-bucketed bars, themable via CSS currentColor. Buckets cached 90 days; cold render ~2 s, cached render < 200 ms. Pure SVG — no Pillow/PIL dep added.
  • New extended object on /lookup responses — surfaces the AcousticBrainz higher-level classifications (gender, timbre, tonal_atonal) that were previously dropped. Returned as a nested object so it doesn't change the 37-field headline. Null when no AB match exists.

All three are quota-counted at 1 token. Audio for waveform generation is held briefly in /dev/shm (RAM tmpfs) and unlinked the moment librosa returns — same "no audio on durable storage" guarantee as /analyze and /identify.

BPM correction, cover art proxy, genre normalisation

Three quality-of-life improvements that compound across every existing endpoint.

  • New bpm_alt field on every /lookup and /analyze response. Half-time / double-time corrected BPM, populated when our heuristic suggests Essentia locked onto the wrong beat grid (e.g. Blinding Lights now returns bpm: 85.39, bpm_alt: 170.78). Never replaces bpm — it's a sibling so existing integrations are unaffected. Field count bumped 36 → 37.
  • New GET /track/<id>/artwork?size=300|600|1200 — 302-redirect to a cover-art image. Resolution order: iTunes Lookup API for numeric catalog ids, then Cover Art Archive via the track's MusicBrainz release MBIDs. Hits cached 30 days; misses too.
  • Genre normalisation — idempotent SQL pass at scripts/normalize_genres.py, also wired into the post-ingest pipeline so future drift gets cleaned automatically. Variants like "Classic Rock"rock, "rnb"r&b, decade tags ("80s") → NULL.

POST /identify — audio fingerprint recognition at 5× lower cost than AudD

Upload any audio clip and get back the recording it represents — title, artist, MusicBrainz ID, ISRC, plus full FreqBlog audio features when the matched recording is in our catalog.

  • Chromaprint for fingerprinting (libchromaprint-tools added to the container) and AcoustID for matching against an open dataset of 60M+ recordings.
  • Audio is held briefly in /dev/shm (RAM-backed tmpfs) for fingerprinting and unlinked the moment fpcalc returns — no audio is written to durable storage.
  • When the matched MBID resolves to a catalog row, the response includes the full 37-field audio_features object — one call instead of two.
  • Charges 2 quota requests per identification (vs 1 for /analyze) since AcoustID adds an upstream call. On Pro that works out to £0.34 per 1,000 recognitions.
  • Same per-tier concurrency lanes as /analyze — CPU-bound work is gated by the existing semaphores.
  • Python and Node SDKs gain fb.identify(audio_path) / fb.identify(audioBytes).

Endpoint returns 503 not configured until the server has ACOUSTID_API_KEY set (free key for non-commercial use; commercial agreement required for paid tiers).

Plug the leaks — ops, billing, and rate-limit fixes

Eight under-the-hood improvements that fix broken-by-default behaviour or remove tail risk. Nothing here changes the public API surface.

  • Annual billing actually works. The 15%-off toggle on the pricing page now passes ?billing=annual through to the checkout endpoint, which prefers your annual Stripe price IDs (with a clean fall-back to monthly when no annual price is configured for a plan).
  • New GET /me/portal — redirects to the Stripe Customer Portal so paying customers can self-serve plan changes, payment-method updates, and cancellations.
  • Better 429 messaging — quota-exceeded responses now carry a structured {tier, quota, used, upgrade_to, upgrade_url} payload so client error handlers can surface a one-click upgrade.
  • New GET /health/lookup — data-path probe (no auth). Goes one level deeper than /health by running an internal lookup; returns 200 only if features actually come back. Wire your monitoring to this for real "is the API working" alerts, not just "is the process up".
  • Cloudflare real-IP trust — nginx now uses CF-Connecting-IP from the published Cloudflare ranges, so per-IP rate limits are keyed off the actual client (not the CF edge). Refreshed monthly via cron.
  • Nightly SQLite maintenancePRAGMA wal_checkpoint(TRUNCATE), 90-day backfill-jobs retention, 30-day attempts/notifications retention, 90-day request-log retention. Keeps the DB snappy without manual intervention.
  • Off-host backup scriptrclone-based push of nightly archives to a cloud bucket (B2 / R2 / S3 / etc.). Disabled until configured; setup notes in the script header.
  • Stripe Checkout metadata — sessions now record plan and billing so the webhook can correctly attribute annual vs monthly subscriptions when they reconcile.

Webhooks, SDKs, /bulk-csv, and a public Security page

Four pieces of "infra-team checklist" that often block production. Existing endpoints are unchanged.

  • Backfill webhooks — configure an HTTPS callback via POST /me/webhook (also GET / DELETE). Each fired POST is signed with X-FreqBlog-Signature: sha256=<hex>; the HMAC secret is sha256(sha256(your_api_key_plaintext)), so you can verify authenticity with the SDK's one-line helper.
  • Single-file SDKsfreqblog.py (~200 lines, httpx only) and freqblog.js (~150 lines, zero deps on Node 18+). Drop-in, no build, no version pinning. Both ship with verify_webhook() / verifyWebhook(). Hosted at /sdk/.
  • POST /bulk-csv — upload a CSV with artist,title columns and receive an enriched CSV with all 26 audio-feature columns appended. Up to 500 rows per call. Original columns preserved untouched.
  • /security — plain-language page covering audio retention (zero), customer data inventory, subprocessors (Hostinger, Cloudflare, Stripe, Resend, UptimeRobot), GDPR posture, DPA contact, and incident-reporting process.

Charts, harmonic radio, and DJ-format exports — the Mixed In Key replacement set

Four new endpoints turning the API into a DJ tooling backend — pull a chart, walk a harmonic playlist, and export a Rekordbox / Serato / Traktor-ready file in one chained call.

  • GET /charts/<country> — live Apple Music top-songs feed for 45 ISO countries (us, gb, jp, de, mx, za, …), cross-referenced against the catalog and cached for 12 hours. Each entry tells you whether it's already analysed, so you can pipe a country chart straight into /lookup or /similar.
  • GET /radio?seed_track_id=…&n=20 — harmonic + BPM-continuity playlist generator. A greedy walk over the similarity index that respects Camelot wheel adjacency (max_key_distance) and per-step BPM drift (bpm_drift). Returns tracks in play order; same-song duplicates are de-duped by (artist, title) so multiple catalog rows for the same recording don't stack.
  • GET /export/rekordbox?track_ids=… — Pioneer Rekordbox XML 1.0.0. Each track carries AverageBpm, Tonality (Camelot), and a POSITION_MARK hot cue at the highest-energy 30-second window — that's a "Chorus" cue out of the box.
  • GET /export/m3u + GET /export/cuesheet — Extended M3U8 (Serato, Traktor, Engine DJ, VirtualDJ) and a plain-text cue sheet for chats and notebooks.

All four endpoints count as one quota request each. Track audio is referenced via freqblog://<id> — relink to your local audio after import using your DJ software's relink tool.

/similar — cosine similarity over the entire catalog in one call

Pass a track id, get the most acoustically similar tracks in the catalog ranked by cosine similarity over an 18-feature audio embedding (BPM, key, energy, loudness, danceability, valence, mood, AcousticBrainz descriptors).

  • Sub-millisecond ranking via a single matrix multiply over the in-memory index (47k rows × 18 dims, ~6.5 MB).
  • Per-feature z-score normalisation uses real values only, so each feature contributes equally regardless of raw scale and missing-feature defaults don't bias the rankings.
  • Quality gate — tracks with fewer than 6 real audio features are filtered from both seed and candidate lists, so under-sampled tracks don't pollute results.
  • Cross-artist filter — pass exclude_same_artist=true for "more like this, but different" discovery feeds.
  • Same flat pricing as everything else: £0.17 per 1,000 requests on Pro, no surprise per-recommendation fees.

Five new catalog endpoints — search, browse, embeddings, harmonic helper

Doubling the API surface with five new endpoints that build on the then-47.6k-track catalog. None of them touch the response shape of /lookup, so existing integrations are unaffected.

  • GET /search?q=... — full-text search across track / artist / album, backed by SQLite FTS5. Each whitespace-separated token is matched as a prefix, ranked by BM25 then popularity. Returns lightweight track stubs.
  • GET /artist/tracks?artist=... — list every catalog track for an artist, paginated.
  • GET /genres — every genre in the catalog with its track count, sorted by count.
  • GET /genres/<name>/tracks — tracks tagged with a given genre, paginated.
  • GET /key/<camelot>/compatible — pure-logic helper returning harmonically compatible keys (same, relative, adjacent_up, adjacent_down, optionally energy_boost/energy_drop). No quota cost.
  • GET /track/<id>/embedding — project a track's features into an 18-dimensional numeric vector, ready for similarity search, clustering, or feeding into your own ML model. Returns embedding, embedding_mask, and fields.

Each endpoint counts as a single quota request regardless of how many results are returned, except /key/<camelot>/compatible which is unauthenticated and unbilled.

Public status & changelog pages, annual billing, and a clearer pitch

Today's site refresh focuses on transparency and pricing legibility:

  • Public status page at /status.html showing live uptime and response time for the API and the marketing site.
  • This changelog at /changelog.html — a public record of what we ship.
  • Per-1k unit pricing shown under every plan tier, in any of 8 currencies. The Pro plan works out to £0.17 per 1,000 requests — about 10× cheaper than AudD and 80× cheaper than MusicAPI.com on equivalent volume.
  • Annual billing toggle — 15% off if you pay yearly.
  • Clearer hero pitch — the API is the drop-in replacement for Spotify's deprecated audio_features endpoint, and the homepage now leads with that.

Spotify ISRC enrichment, auto-resuming on rate limits

Pass-B Spotify ISRC enrichment kicked off, writing the first batch of ISRCs sourced directly from Spotify (tagged via a new isrc_source column so you can audit provenance). When Spotify trips the daily quota, the job persists progress and resumes itself when the rate-limit window expires — no manual restarts.

We also fixed a regression in the on-demand ingest worker that had been silently dropping /lookup backfills since Apr 27. Replayed the affected jobs as digest emails so no requested track was lost.

External monitoring and weekly funnel digest

Added an external UptimeRobot health-check on /health at 5-minute intervals, alerting on a missing "ok" keyword. The dashboard now surfaces 24h / 7d / 30d uptime ratios and last response time live.

A new weekly funnel digest goes out every Monday at 08:00 UTC summarising signups, first-call conversion, dormant accounts and per-endpoint error rates — so we can see real friction without trawling logs.

Four new audio descriptors on every /lookup

Adding four MTG-Essentia descriptors to the response, sourced from the AcousticBrainz low-level feature dump (~7.5M MBID-keyed tracks):

  • onset_rate — onset events per second; higher means more rhythmic / percussive.
  • dynamic_complexity — loudness variation across the track (dB); higher = wider dynamic range.
  • tuning_frequency — estimated A4 reference in Hz. Modern recordings cluster near 440.
  • average_loudness — perceptually-normalised average loudness (0–1).

The fields are null when the track isn't in the frozen July-2022 AcousticBrainz dataset (e.g. post-2022 recordings). Verified live with Smells Like Teen Spirit (all four populated) versus Levitating (all four null).

feature_source field for full provenance

Every /lookup and /analyze response now includes a feature_source field telling you exactly where each track's features came from: essentia_preview, acousticbrainz, fma, msd, or user_upload. Useful when comparing accuracy across sources or filtering by your trust threshold.

Catalog quality pass — non-commercial filters and dead sources retired

Tightened catalog quality:

  • Removed Billboard Year-End Hot 100 ingest — most 1960s–80s tracks 403'd at the iTunes CDN, generating thousands of daily errors for ~30 successful ingests.
  • Disabled Jamendo CC ingest and purged 3,560 amateur tracks from the catalog.
  • Block list now filters out instrumental, karaoke, live, demo, bootleg, slowed/sped-up variants (nightcore, daycore), white noise, lullabies, binaural, sleep / rain / nature sounds, meditation / relaxation / healing music.
  • Added new seed categories: 2020–2024 Hits, decade-specific best-ofs, Motown Soul, Disco Classics, Number 1 Hits UK, Festival Anthems, Dancefloor Classics, Summer Anthems.

Net effect: fewer junk results, better signal on real-world commercial music.

Dashboard "Last Ingest" and Phase 2 stats now correct

Fixed two longstanding accuracy bugs in the dashboard's Music Metadata API card:

  • "Last Ingest" timestamp was sorting strings alphabetically by day-of-week name — meaning a Sunday run could shadow a newer Monday run. Now uses a proper datetime comparator.
  • "New / Skip / Err" Phase 2 counters were summing every ingest run ever recorded in the slice log, inflating the numbers. Now shows only the most recent run.

Building on the API?

Get a free key in 15 seconds — no card, no org account, no waitlist.

Get a free key