Why watch labels at all
Most release-monitoring stories start from “watch this artist”. That works for fans but not for the people whose job it is to keep track of the music industry — A&R scouts, sample-clearance managers, sync agents, playlist curators. They watch labels, because labels signal taste consistency: if you know Drumcode well, you also know the kind of thing Drumcode signs.
A focused watchlist of 10–20 labels covers most of a working A&R's competitive scope. The whole pipeline is one cron job, one Slack webhook, and a JSON file to remember what's already been seen.
/v1/releases/new returns every release across the catalog — thousands per day. For a watchlist of 20 labels, /v1/labels/:id/releases is much cheaper: 20 calls per run, each capped at the most recent ~20 releases per label.Bootstrap: get the IDs for your watched labels
One-off setup — search for each label by name and record the ID. Hard-code these in the cron after the first run; label IDs are stable forever:
// One-off bootstrap: search for each watched label and write down its ID. const WATCHED = ["Drumcode", "Ninja Tune", "Hospital Records", "Defected"]; async function findLabelId(name: string): Promise<number | null> { const res = await fetch(`${BASE}/labels/search?${new URLSearchParams({ name, limit: "1" })}`, { headers: { "x-api-key": API_KEY }, }); const { results } = await res.json(); return results[0]?.id ?? null; } const ids = await Promise.all(WATCHED.map(findLabelId)); console.log(ids); // [1, 42, 128, 256]
The cron: pull, diff, post
The whole loop is small. Load yesterday's “seen IDs” state, pull each label's recent releases, diff to find anything new, post each to Slack, write back the updated state:
import fs from "node:fs/promises"; const API_KEY = process.env.SONOVAULT_API_KEY!; const SLACK_HOOK = process.env.SLACK_WEBHOOK_URL!; const BASE = "https://api.sonovault.now/v1"; const STATE_FILE = "./.state/seen.json"; interface WatchedLabel { id: number; name: string; } interface Release { id: number; title: string; artist: { id: number; name: string }; release_date: string; catalog_no?: string; track_count: number; } const WATCHED: WatchedLabel[] = [ { id: 1, name: "Drumcode" }, { id: 42, name: "Ninja Tune" }, { id: 128, name: "Hospital Records" }, { id: 256, name: "Defected" }, ]; // 1. Load the seen-release-IDs state. Empty on first run. async function loadState(): Promise<Record<number, number[]>> { try { return JSON.parse(await fs.readFile(STATE_FILE, "utf-8")); } catch { return {}; } } // 2. Pull recent releases for one label (one page is plenty for hourly runs). async function recent(labelId: number): Promise<Release[]> { const res = await fetch(`${BASE}/labels/${labelId}/releases?limit=20`, { headers: { "x-api-key": API_KEY }, }); const { results } = await res.json(); return results; } // 3. Post a release to Slack as a single block. async function notify(label: WatchedLabel, r: Release) { const text = `*New release on ${label.name}:* ${r.artist.name} — ${r.title}` + `\nReleased ${r.release_date} · ${r.track_count} tracks` + (r.catalog_no ? ` · ${r.catalog_no}` : ""); await fetch(SLACK_HOOK, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }), }); } // 4. The cron entrypoint. async function main() { const state = await loadState(); let newCount = 0; for (const label of WATCHED) { const releases = await recent(label.id); const seen = new Set(state[label.id] ?? []); const fresh = releases.filter(r => !seen.has(r.id)); for (const r of fresh) { await notify(label, r); newCount++; } state[label.id] = releases.map(r => r.id); } await fs.mkdir(".state", { recursive: true }); await fs.writeFile(STATE_FILE, JSON.stringify(state)); console.log(`Posted ${newCount} new release(s) to Slack`); } main().catch(err => { console.error(err); process.exit(1); });
Schedule it
On a server, one crontab line:
# Run hourly via crontab. Adjust path to your project. # Every hour at :05 (give labels time to publish at the top of the hour). 5 * * * * cd /opt/ar-bot && /usr/bin/env node dist/cron.js >> /var/log/ar-bot.log 2>&1
On GitHub Actions or another serverless host, the equivalentcron: trigger. The state file is tiny — under 1KB per label — so a checked-in .state/seen.json with commit-back works fine.
Going further
- Add a notes channel for “hot” releases.If a release's primary artist has high
release_countor recognisedwikidata_id, post to a separate Slack channel for marquee drops. Fetch via/v1/artists/:id. - Attach platform links.Resolve each new release's tracks via
/v1/tracks/linksand include “Listen on Spotify” / “Buy on Beatport” buttons in the Slack message — see the Listen On / Buy On buttons article for the URL construction. - Daily digest instead of real-time. Replace the per-release Slack call with a single end-of-day summary message listing everything new across all watched labels.
Frequently asked questions
Why poll the label endpoints instead of /v1/releases/new?
/v1/releases/newreturns every release across the catalog — for a focused watchlist of 10–20 competitor labels, you'd download thousands of releases per pull just to filter most of them out. Polling each label's own /releases endpoint is much more targeted: one cheap call per label, max ~20 results to diff against state.
How often should this run?
Once an hour is plenty for an A&R use case — most labels announce drops a day or two ahead and surface them in the API the morning of release. Once a day is fine if you want a single morning digest. Faster than hourly burns quota with no real signal.
What's the state file for? Couldn't I just store the last-seen timestamp?
A timestamp misses releases that were back-dated, re-imported, or had their date corrected. Storing the set of seen release IDs per label is bulletproof — anything you haven't seen is a new release, regardless of date.
Can I run this without a server?
Yes — GitHub Actions, Cloudflare Workers Cron Triggers, Vercel Cron, AWS EventBridge + Lambda all work. The state file fits in a tiny KV store or even a checked-in JSON file if you use GitHub Actions with a commit-back step. The pipeline itself is stateless apart from that one file.