SonoVault is in open beta — signups are live. Get your free API key →
ResourcesMusic TrackingA&R Workflow: Slack Alerts for Competitor Label Drops

A&R Workflow: Slack Alerts for Competitor Label Drops

End-to-end cron job. Watch a list of competitor labels, diff new releases against a state file, and post fresh drops to a Slack webhook. ~80 lines of TypeScript and one Node cron.

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.

Why per-label polling, not a firehose. /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.
Build
1

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:

TypeScriptfind-label-ids.ts
// 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]
2

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:

TypeScriptcron.ts
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); });
💡The first run will post every recent release for every watched label — that's usually 50–200 messages. Either dump them into a temporary channel for the first run, or seed the state file by writing it out without sending Slack messages.
3

Schedule it

On a server, one crontab line:

bashcrontab
# 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_count or recognised wikidata_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.

Ready to build?

Free API key. No credit card. 1,000 requests to get started.

Get Free API Key
More in Music Tracking
Music TrackingBuilding a Genre-Filtered Release Radar6 min readMusic TrackingHow to Track New Releases from a Record Label6 min read