artistsentry

Your catalogue, in your own systems.

Everything the app does, over HTTP. Add a roster, read what turned up overnight, answer it and follow a case to its outcome, all from the tools your team already works in.

Included with Scale

The API comes with the Scale plan at no extra cost. Everything below is open to you: watch artists, read what we find, answer it, and follow a case to its outcome. Nothing here contacts anybody: a case carries the evidence and who to write to, and that part is yours.

See what else is on the plan.

Authenticating

Every request takes your key as a bearer token. Keys are created in the app, on the Scale plan.

Request
curl https://www.artistsentry.com/api/v1/detections \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Accept: application/json"

Scopes

A key is limited to the scopes it was created with. Anything outside them returns 403.

artists:read
List artists and their linked profiles.
artists:write
Create artists, import rosters, link profiles, trigger scans.
detections:read
List detections and deliveries.
detections:write
Answer detections and acknowledge deliveries. Disputing opens cases.
disputes:read
List takedown cases, their evidence and who to contact.
disputes:write
Record that a service was contacted, and how a case ended.

Rate limits

120 requests a minute, shared across every key on the team.

Every response carries the two headers alongside. Once Remaining hits zero the next request is answered 429, with Retry-After giving the seconds to wait. If you need a higher limit, reach out to our team.

Response
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117

Pagination

Every list takes page and per_page, and answers with the same links and meta alongside the data. per_page tops out at 200 and defaults to 50.

Follow links.next until it is null. meta.links is there for drawing a pager and can be ignored.

Response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": [ … ],
  "links": {
    "first": "https://www.artistsentry.com/api/v1/detections?page=1",
    "last": "https://www.artistsentry.com/api/v1/detections?page=4",
    "prev": null,
    "next": "https://www.artistsentry.com/api/v1/detections?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 4,
    "path": "https://www.artistsentry.com/api/v1/detections",
    "per_page": 50,
    "to": 50,
    "total": 183,
    "links": [ … ]
  }
}

Errors

401
Missing, malformed or revoked key.
403
Plan does not include the API, or the key lacks the required scope.
404
No such record on this team. Returned instead of 403 so a key cannot probe another team.
422
Validation failed. The response names the fields.
429
Rate limit exceeded. Retry-After says how long to wait.
Response
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "message": "Invalid ability provided."
}

Webhooks

We POST to a URL you own when something happens. Each payload carries the same resource the matching GET returns, so a consumer written against the API already parses it.

Signature

Every request carries ArtistSentry-Signature: t=<unix>,v1=<hmac>. v1 is HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint secret.

Hash the raw request bytes. Re-serialising the JSON reorders keys and changes whitespace, and the signature will not match.

There can be more than one v1. While a rotation overlap is open the header carries a signature under the new secret and one under the old, current first, so a receiver that has deployed the new secret and one that has not both verify. Treat a match against any v1 as valid rather than parsing only the first or the last.

Reject timestamps older than 5 minutes. The timestamp is inside the signed string so it cannot be rewritten, but replaying the original request verifies fine without an age check.

Responses

2XX is success. Any other status retries on widening backoff (1m, 5m, 15m, 45m, 2h, 4h, 8h) for 24 hours, then the event is dropped. 4XX retries the same as 5XX.

Three consecutive dropped events disable the endpoint and email the team owner. Any 2XX resets the counter, and one arriving after an automatic switch-off turns the endpoint back on: an endpoint that has recovered does not stay dark on the strength of failures from a day ago. A pause you set yourself is never lifted this way.

Everything sent is kept: both sides of the exchange, headers and bodies, browsable per endpoint in your console. Deliveries that were given up on are kept far longer than ones that landed, since they are the only record of an event you were never told about.

Read timeout is 10 seconds. Acknowledge first, process after.

Ordering and duplicates

No ordering guarantee. Deliveries are rate limited to 60/min per endpoint and retried independently. Sort on occurred_at.

Expect duplicates. A 2XX arriving after the 10 second timeout is recorded as a failure and retried. Deduplicate on id, which is stable across retries and across endpoints.

Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_5310
ArtistSentry-Topic: ping
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_5310",
  "type": "ping",
  "api_version": "v1",
  "occurred_at": "2026-08-17T18:30:00+00:00",
  "data": {
    "message": "This is a test from your ArtistSentry console.",
    "endpoint": "Catalogue pipeline"
  }
}

Constant-time comparison throughout. A plain equality check leaks the signature a byte at a time to anyone measuring how long you take to reject it.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyArtistSentrySignature
{
    public function handle(Request $request, Closure $next): Response
    {
        [$timestamp, $signatures] = $this->parse($request->header('ArtistSentry-Signature', ''));

        $expected = hash_hmac(
            'sha256',
            $timestamp.'.'.$request->getContent(),
            config('services.artistsentry.webhook_secret'),
        );

        $matched = false;

        // Every v1 is checked. During a rotation overlap there are two.
        foreach ($signatures as $signature) {
            if (hash_equals($expected, $signature)) {
                $matched = true;
            }
        }

        abort_unless($matched, 403);
        abort_if(abs(time() - $timestamp) > 300, 403);

        return $next($request);
    }

    /** @return array{0: int, 1: array<int, string>} */
    private function parse(string $header): array
    {
        $timestamp = 0;
        $signatures = [];

        foreach (explode(',', $header) as $part) {
            [$key, $value] = array_pad(explode('=', $part, 2), 2, '');

            match ($key) {
                't' => $timestamp = (int) $value,
                'v1' => $signatures[] = $value,
                default => null,
            };
        }

        return [$timestamp, $signatures];
    }
}
$secret = getenv('ARTISTSENTRY_SECRET');
$body = file_get_contents('php://input');

$timestamp = 0;
$signatures = [];

foreach (explode(',', $_SERVER['HTTP_ARTISTSENTRY_SIGNATURE'] ?? '') as $part) {
    [$key, $value] = array_pad(explode('=', $part, 2), 2, '');

    if ($key === 't') {
        $timestamp = (int) $value;
    } elseif ($key === 'v1') {
        $signatures[] = $value;
    }
}

$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);

$matched = false;

// Check every v1: a rotation overlap sends the old and the new.
foreach ($signatures as $signature) {
    if (hash_equals($expected, $signature)) {
        $matched = true;
    }
}

if (! $matched || abs(time() - $timestamp) > 300) {
    http_response_code(403);
    exit;
}

http_response_code(200);
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Raw body, not express.json(). Parsing and re-serialising breaks the hash.
app.post('/hooks', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = (req.get('ArtistSentry-Signature') || '')
    .split(',')
    .map((p) => p.split('='));

  const timestamp = parts.find(([k]) => k === 't')?.[1] ?? '';
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);

  const expected = crypto
    .createHmac('sha256', process.env.ARTISTSENTRY_SECRET)
    .update(`${timestamp}.`)
    .update(req.body)
    .digest();

  // Any v1 may match: a rotation overlap sends the old and the new.
  const matched = signatures.some((signature) => {
    const received = Buffer.from(signature, 'hex');

    return expected.length === received.length
      && crypto.timingSafeEqual(expected, received);
  });

  if (!matched) return res.sendStatus(403);
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(403);

  const event = JSON.parse(req.body.toString('utf8'));

  res.sendStatus(200);
});
// Web Crypto, so this runs on Workers, Deno, Bun and Node 18+.
const encoder = new TextEncoder();

export async function verify(request: Request, secret: string): Promise<string | null> {
  const body = await request.text();

  const parts = (request.headers.get('ArtistSentry-Signature') ?? '')
    .split(',')
    .map((p) => p.split('=') as [string, string]);

  const timestamp = parts.find(([k]) => k === 't')?.[1] ?? '';
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);

  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );

  const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(`${timestamp}.${body}`));

  const expected = [...new Uint8Array(mac)]
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');

  // Any v1 may match: a rotation overlap sends the old and the new.
  if (!signatures.some((signature) => equals(expected, signature))) return null;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return null;

  return body;
}

function equals(a: string, b: string): boolean {
  if (a.length !== b.length) return false;

  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);

  return diff === 0;
}
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func Verify(r *http.Request, secret []byte) ([]byte, bool) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, false
	}

	var ts string
	var sigs []string

	for _, part := range strings.Split(r.Header.Get("ArtistSentry-Signature"), ",") {
		switch k, v, _ := strings.Cut(part, "="); k {
		case "t":
			ts = v
		case "v1":
			sigs = append(sigs, v)
		}
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	expected := []byte(hex.EncodeToString(mac.Sum(nil)))

	// Any v1 may match: a rotation overlap sends the old and the new.
	matched := false
	for _, sig := range sigs {
		if hmac.Equal(expected, []byte(sig)) {
			matched = true
		}
	}

	if !matched {
		return nil, false
	}

	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || time.Since(time.Unix(sec, 0)).Abs() > 5*time.Minute {
		return nil, false
	}

	return body, true
}
import hashlib
import hmac
import time


def verify(header: str, body: bytes, secret: bytes) -> bool:
    """Pass the raw request body. Anything re-serialised will not match."""
    timestamp = ""
    signatures = []

    for part in header.split(","):
        key, _, value = part.partition("=")

        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)

    expected = hmac.new(
        secret,
        f"{timestamp}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()

    # Any v1 may match: a rotation overlap sends the old and the new.
    if not any(hmac.compare_digest(expected, s) for s in signatures):
        return False

    return abs(time.time() - int(timestamp or 0)) <= 300


# Flask
# verify(request.headers.get("ArtistSentry-Signature", ""), request.get_data(), SECRET)

# Django
# verify(request.headers.get("ArtistSentry-Signature", ""), request.body, SECRET)
GET /api/v1/artists

List the artists on this team, with their linked profiles. Ordered by name.

Needs artists:read
Returns 200
page integer
Which page to return. Default 1.
per_page integer
Max 200. Default 50.
  • links and meta are the same on every list. See Pagination.
Request
curl https://www.artistsentry.com/api/v1/artists \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": [
    {
      "id": 12,
      "name": "Corrin Ashe",
      "slug": "corrin-ashe",
      "monitoring_enabled": true,
      "known_distributors": ["Fairlane Digital"],
      "baselined_at": "2026-07-02T11:20:41+00:00",
      "pending_count": 1,
      "profiles": [
        {
          "id": 41,
          "service": "spotify",
          "service_name": "Spotify",
          "url": "https://open.spotify.com/artist/4kPCyOKK…",
          "watched": true,
          "last_scanned_at": "2026-08-13T04:12:09+00:00",
          "needs_channel_choice": false,
          "channel_candidates": []
        }
      ]
    }
  ],
  "links": { … },
  "meta": { … }
}
POST /api/v1/artists

Start watching an artist. The link is opened against the service before the artist is created, so a mistyped id is refused here rather than becoming a profile that is scanned forever and never returns anything.

Needs artists:write
Returns 201
name string required
Max 255.
profile_url string required
Artist page on Spotify, Apple Music, Deezer, TIDAL or YouTube Music. Any other platform is rejected.
https://open.spotify.com/artist/4kPCyOKKQXCPOOO4gMzOSj
https://music.apple.com/gb/artist/corrin-ashe/1544912847
https://www.deezer.com/en/artist/8675309
https://tidal.com/browse/artist/39109089
https://www.youtube.com/@corrinashe
https://www.youtube.com/channel/UC8B7Iq8sy2QuBpZpTfsjLmA
known_distributors string[]
Distributors already in use. Releases from these are not questioned. Max 255 each.
  • pending_count is only on the list.
Request
curl -X POST https://www.artistsentry.com/api/v1/artists \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Corrin Ashe",
    "profile_url": "https://open.spotify.com/artist/4kPCyOKK…"
  }'
Response
{
  "data": {
    "id": 12,
    "name": "Corrin Ashe",
    "slug": "corrin-ashe",
    "monitoring_enabled": true,
    "known_distributors": [],
    "baselined_at": null,
    "profiles": [
      {
        "id": 41,
        "service": "spotify",
        "service_name": "Spotify",
        "url": "https://open.spotify.com/artist/4kPCyOKK…",
        "watched": true,
        "last_scanned_at": null,
        "needs_channel_choice": false,
        "channel_candidates": []
      }
    ]
  }
}
POST /api/v1/artists/import

Create many artists at once. Each artist carries every link they are found under, so the same act on Spotify and TIDAL is one artist rather than two. Every link is opened against its service before the artist is created, so this is queued rather than answered here: poll the status url.

Needs artists:write
Returns 202
artists array required
Max 500 per request.
artists[].name string
Max 255. Read off the first link if you leave it out, and replaced by the real one at the first scan.
artists[].urls array required
One or more artist pages on Spotify, Apple Music, Deezer, TIDAL or YouTube Music. An artist with any unreadable link fails whole rather than being created half watched.
Request
curl -X POST https://www.artistsentry.com/api/v1/artists/import \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "artists": [
      {
        "name": "Corrin Ashe",
        "urls": [
          "https://open.spotify.com/artist/4kPCyOKKQXCPOOO4gMzOSj",
          "https://tidal.com/browse/artist/39109089"
        ]
      },
      { "urls": ["https://www.deezer.com/en/artist/8675309"] }
    ]
  }'
Response
{
  "data": {
    "id": 4021,
    "status": "queued",
    "artists": 2,
    "status_url": "https://www.artistsentry.com/api/v1/artists/imports/4021",
    "added": [],
    "failed": [],
    "completed_at": null
  }
}
GET /api/v1/artists/imports/{id}

Check how a roster is getting on. Answers while it runs, not only once it is over: added and failed fill in as the roster is worked through, so you can act on the artists already watching. Either artists scope reads it, so the key that started the import can always follow it. Status is queued, running, completed or failed.

Needs artists:read
Returns 200
Request
curl https://www.artistsentry.com/api/v1/artists/imports/4021 \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": {
    "id": 4021,
    "status": "completed",
    "artists": 2,
    "status_url": "https://www.artistsentry.com/api/v1/artists/imports/4021",
    "added": [
      {
        "id": 12,
        "name": "Corrin Ashe",
        "slug": "corrin-ashe",
        "monitoring_enabled": true,
        "known_distributors": [],
        "baselined_at": null,
        "profiles": [
          {
            "id": 41,
            "service": "spotify",
            "service_name": "Spotify",
            "url": "https://open.spotify.com/artist/4kPCyOKK…",
            "watched": true,
            "last_scanned_at": null,
            "needs_channel_choice": false,
            "channel_candidates": []
          }
        ]
      }
    ],
    "failed": [
      {
        "name": "Nobody",
        "url": "https://example.com/nope",
        "reason": "That doesn't look like an artist page link."
      }
    ],
    "completed_at": "2026-08-13T10:44:02+00:00"
  }
}
POST /api/v1/artists/{id}/profiles

Link another service profile to an artist already being watched. Returns the artist with every profile. Linking one that is already there changes nothing and answers 200.

Needs artists:write
Returns 201 · 200 if already linked
profile_url string required
Same platforms as above.
Request
curl -X POST https://www.artistsentry.com/api/v1/artists/12/profiles \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"profile_url": "https://tidal.com/browse/artist/39109089"}'
Response
{
  "data": {
    "id": 12,
    "name": "Corrin Ashe",
    "slug": "corrin-ashe",
    "monitoring_enabled": true,
    "known_distributors": [],
    "baselined_at": "2026-07-02T11:20:41+00:00",
    "profiles": [
      {
        "id": 41,
        "service": "spotify",
        "service_name": "Spotify",
        "url": "https://open.spotify.com/artist/4kPCyOKK…",
        "watched": true,
        "last_scanned_at": "2026-08-13T04:12:09+00:00",
        "needs_channel_choice": false,
        "channel_candidates": []
      },
      {
        "id": 63,
        "service": "tidal",
        "service_name": "TIDAL",
        "url": "https://tidal.com/browse/artist/39109089",
        "watched": true,
        "last_scanned_at": null,
        "needs_channel_choice": false,
        "channel_candidates": []
      }
    ]
  }
}
POST /api/v1/artists/{id}/profiles/{profileId}/rescan

Queue an immediate scan of one profile, rather than waiting for the next scheduled one. Returns the artist as it stands now; last_scanned_at moves once the scan runs.

Needs artists:write
Returns 202
  • The same artist object as everywhere else.
Request
curl -X POST https://www.artistsentry.com/api/v1/artists/12/profiles/41/rescan \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": { "id": 12, "name": "Corrin Ashe", "…": "…" }
}
GET /api/v1/detections

List releases found on this team, newest first. Everything by default, not only what is waiting on an answer.

Needs detections:read
Returns 200
status string
baseline, pending_review, confirmed, disputed, removed
artist_id integer
Only releases by this artist.
since date
Only releases first seen on or after this date.
page integer
Which page to return. Default 1.
per_page integer
Max 200. Default 50.
  • enriched_at null means we have not looked yet. distributor null with enriched_at set means nobody publishes one. The difference is reported rather than flattened.
  • links and meta are the same on every list. See Pagination.
Request
curl "https://www.artistsentry.com/api/v1/detections?status=pending_review" \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": [
    {
      "id": 4821,
      "title": "Paper Streets",
      "type": "album",
      "status": "pending_review",
      "needs_answer": true,
      "artist": { "id": 12, "name": "Corrin Ashe" },
      "released_on": "2026-08-11",
      "first_seen_at": "2026-08-11T06:02:18+00:00",
      "enriched_at": "2026-08-11T09:14:44+00:00",
      "upc": "850214907733",
      "isrc": "GBKQU2600114",
      "label": "Broadwater Records",
      "distributor": "Fairlane Digital",
      "copyright": "℗ 2026 Broadwater Records",
      "artwork_url": "https://i.scdn.co/image/ab67616d…",
      "credited_artists": ["Corrin Ashe"],
      "recordings": [
        { "isrc": "GBKQU2600114", "title": "Paper Streets" }
      ],
      "matches_known_distributor": false,
      "listings": [
        {
          "service": "spotify",
          "service_name": "Spotify",
          "url": "https://open.spotify.com/album/…",
          "distributor": null,
          "first_seen_at": "2026-08-11T06:02:18+00:00",
          "removed_at": null,
          "needs_answer": false
        }
      ]
    }
  ],
  "links": { … },
  "meta": { … }
}
GET /api/v1/detections/{id}

Retrieve one detection.

Needs detections:read
Returns 200
  • The same fields as on the list.
Request
curl https://www.artistsentry.com/api/v1/detections/4821 \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": { "id": 4821, "title": "Paper Streets", "…": "…" }
}
POST /api/v1/detections/{id}/answer

Confirm a release, or dispute it. Disputing opens a case for every service it is live on and returns them with their ids. Each case carries the evidence and who to contact; contacting them is yours to do.

Needs detections:write
Returns 200
answer string required
mine or not_mine.
claim_type string
Required when answer is not_mine. misattribution, impersonation, infringement.
note string
Kept on the release as your own note. Max 2000.
  • data is the detection. disputes are whole cases, each with the evidence gathered for it and who to contact.
  • Answering also settles any delivery on that release that was waiting on one, so those rows leave /deliveries. One release, one question.
  • Answering again with a different claim_type re-points the open cases in place rather than adding more, since a different claim goes to a different desk. Cases you have already recorded contact on are left alone.
Request
curl -X POST https://www.artistsentry.com/api/v1/detections/4821/answer \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "answer": "not_mine",
    "claim_type": "misattribution",
    "note": "Never heard of it."
  }'
Response
{
  "data": { "id": 4821, "status": "disputed", "…": "…" },
  "disputes": [
    { "id": 331, "status": "open", "service": "spotify", "…": "…" },
    { "id": 332, "status": "open", "service": "apple_music", "…": "…" }
  ]
}
GET /api/v1/deliveries

List deliveries waiting on an explanation: a second distributor turning up on a release the artist already has out. incumbent_distributor is the one that already held it.

Needs detections:read
Returns 200
page integer
Which page to return. Default 1.
per_page integer
Max 200. Default 50.
  • links and meta are the same on every list. See Pagination.
Request
curl https://www.artistsentry.com/api/v1/deliveries \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": [
    {
      "id": 918,
      "service": "youtube_music",
      "service_name": "YouTube Music",
      "distributor": "Northgate Media",
      "incumbent_distributor": "Fairlane Digital",
      "acknowledged_at": null,
      "first_seen_at": "2026-08-12T22:41:07+00:00",
      "release": {
        "id": 4821,
        "title": "Paper Streets",
        "artist": "Corrin Ashe"
      }
    }
  ],
  "links": { … },
  "meta": { … }
}
POST /api/v1/deliveries/{id}/explain

Say a delivery was the artist's own doing. settled names every delivery the answer covered, including ones you have not seen yet.

Needs detections:write
Returns 200
moving_library boolean required
true settles every other delivery from this distributor and remembers it against the artist, so releases that move across next week are never questioned. false settles this release only.
Request
curl -X POST https://www.artistsentry.com/api/v1/deliveries/918/explain \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"moving_library": true}'
Response
{
  "data": { "id": 918, "acknowledged_at": "2026-08-13T…", "…": "…" },
  "settled": [
    { "id": 918, "release_id": 4821,
      "distributor": "Northgate Media" },
    { "id": 927, "release_id": 4833,
      "distributor": "Northgate Media" }
  ]
}
GET /api/v1/disputes

List cases, newest first. One per service, because a correction has to reach each of them and each one answers separately. A case carries the evidence and who to contact; contacting them is yours to do.

Needs disputes:read
Returns 200
status string
open, contacted, resolved, refused, dropped
open boolean
true for cases still in play, false for closed ones.
page integer
Which page to return. Default 1.
per_page integer
Max 200. Default 50.
  • recipient is who the case was addressed to at the time, kept on the case itself rather than read back off a channel that can be re-pointed.
  • links and meta are the same on every list. See Pagination.
Request
curl "https://www.artistsentry.com/api/v1/disputes?open=true" \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": [
    {
      "id": 331,
      "status": "open",
      "is_open": true,
      "claim_type": "misattribution",
      "service": "spotify",
      "service_name": "Spotify",
      "reference": null,
      "release": {
        "id": 4821,
        "title": "Paper Streets",
        "artist": "Corrin Ashe",
        "distributor": "Fairlane Digital"
      },
      "recipient": {
        "name": "Fairlane Digital",
        "address": "claims@fairlane.example",
        "method": "email"
      },
      "evidence": { … },
      "opened_at": "2026-08-11T09:20:03+00:00",
      "contacted_at": null,
      "closed_at": null,
      "outcome_note": null
    }
  ],
  "links": { … },
  "meta": { … }
}
GET /api/v1/disputes/{id}

Retrieve one case, including the evidence gathered for it.

Needs disputes:read
Returns 200
  • The same fields as on the list.
Request
curl https://www.artistsentry.com/api/v1/disputes/331 \
  -H "Authorization: Bearer YOUR_KEY"
Response
{
  "data": { "id": 331, "status": "open", "…": "…" }
}
POST /api/v1/disputes/{id}/contacted

Record that you got in touch. Nothing is sent from here, so this is your note of what you did.

Needs disputes:write
Returns 200
reference string
Their ticket or case number. Max 255.
Request
curl -X POST https://www.artistsentry.com/api/v1/disputes/331/contacted \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reference": "TICKET-4821"}'
Response
{
  "data": { "id": 331, "status": "contacted",
            "reference": "TICKET-4821" }
}
POST /api/v1/disputes/{id}/outcome

Record how a case ended, wherever the reply landed.

Needs disputes:write
Returns 200
status string required
resolved, refused, dropped. Anything else is refused: these are the states a case can end at, not every status it has held.
note string
What they said. Max 2000.
Request
curl -X POST https://www.artistsentry.com/api/v1/disputes/331/outcome \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "resolved", "note": "Re-tagged at source."}'
Response
{
  "data": {
    "id": 331,
    "status": "resolved",
    "is_open": false,
    "closed_at": "2026-08-13T11:02:55+00:00",
    "outcome_note": "Re-tagged at source."
  }
}
POST detection.created

New release, awaiting an answer.

Raised when A scan finds a release that was not on the profile before.
  • Not raised on a profile's first scan. Baselining marks the existing catalogue as pre-existing, so linking a profile with a large back catalogue raises nothing.
  • Sent after enrichment, so distributor, label, upc and isrc are already populated.
  • Answer with POST /v1/detections/{id}/answer.
  • data matches GET /v1/detections/{id}.
id integer
Release id. Stable across every event about this release, and the id for /v1/detections/{id}.
title string
As the service reports it.
type string
album, single, ep, compilation, appears_on.
status string
Always pending_review.
needs_answer boolean
Always true.
artist object
id and name of the artist the release was found under.
released_on string|null
YYYY-MM-DD. Services disagree by a day or so, so do not key on it.
first_seen_at string
ISO 8601. First detection, not the release date.
enriched_at string|null
When provenance was last resolved. Null when the lookup has not succeeded, which separates "no distributor published" from "not looked up yet".
upc string|null
Barcode. The same release carries different barcodes on different services, so do not key on it.
isrc string|null
Track level. On a multi-track release this is the lead recording; use recordings[] for the rest.
label string|null
Label as published.
distributor string|null
Null when no service publishes one. Check enriched_at before treating null as absence.
copyright string|null
Copyright line as published.
artwork_url string|null
Hosted by the service. Not guaranteed to stay valid.
credited_artists string[]
Every credited name, including features. Empty when the service publishes none.
recordings object[]
Per track: title and isrc. Empty when none were resolved.
matches_known_distributor boolean
True when distributor appears in the artist's known_distributors.
listings object[]
One per service the release is live on.
listings[].service string
spotify, apple_music, deezer, tidal, amazon_music, youtube_music, soundcloud, anghami, audiomack, awa, iheartradio, kkbox, line_music, netease, qq_music, seven_digital, telmore_musik, yousee_musik.
listings[].service_name string
Display name for the service.
listings[].url string
The release on that service.
listings[].distributor string|null
Who delivered it to this service. Can differ per service for one release.
listings[].first_seen_at string
ISO 8601.
listings[].removed_at string|null
Set once a scan no longer finds it. Null while live.
listings[].needs_answer boolean
True while this delivery is unacknowledged and still live.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_4812
ArtistSentry-Topic: detection.created
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_4812",
  "type": "detection.created",
  "api_version": "v1",
  "occurred_at": "2026-08-17T04:12:11+00:00",
  "data": {
        "id": 991,
        "title": "Cold Harbour",
        "type": "single",
        "status": "pending_review",
        "needs_answer": true,
        "artist": { "id": 12, "name": "Corrin Ashe" },
        "released_on": "2026-08-14",
        "first_seen_at": "2026-08-17T04:12:09+00:00",
        "enriched_at": "2026-08-17T04:12:11+00:00",
        "upc": "0198004119353",
        "isrc": "GBKQU2680114",
        "label": "Fairlane Digital",
        "distributor": "The state51 Conspiracy",
        "copyright": "© 2026 Corrin Ashe",
        "artwork_url": "https://i.scdn.co/image/ab67616d0000b273…",
        "credited_artists": ["Corrin Ashe"],
        "recordings": [{ "title": "Cold Harbour", "isrc": "GBKQU2680114" }],
        "matches_known_distributor": false,
        "listings": [
          {
            "service": "spotify",
            "service_name": "Spotify",
            "url": "https://open.spotify.com/album/2xQ1mR…",
            "distributor": "The state51 Conspiracy",
            "first_seen_at": "2026-08-17T04:12:09+00:00",
            "removed_at": null,
            "needs_answer": false
          }
        ]
      }
}
POST detection.answered

Release confirmed or disputed.

Raised when A detection is answered, in the app or over the API.
  • Also raised for answers made over the API, so you will see your own writes.
  • A disputed release additionally raises dispute.opened, one per live service.
  • Confirming also raises delivery.explained for any delivery still open against the release.
id integer
Release id. Stable across every event about this release, and the id for /v1/detections/{id}.
title string
As the service reports it.
type string
album, single, ep, compilation, appears_on.
status string
confirmed or disputed.
needs_answer boolean
Always false.
artist object
id and name of the artist the release was found under.
released_on string|null
YYYY-MM-DD. Services disagree by a day or so, so do not key on it.
first_seen_at string
ISO 8601. First detection, not the release date.
enriched_at string|null
When provenance was last resolved. Null when the lookup has not succeeded, which separates "no distributor published" from "not looked up yet".
upc string|null
Barcode. The same release carries different barcodes on different services, so do not key on it.
isrc string|null
Track level. On a multi-track release this is the lead recording; use recordings[] for the rest.
label string|null
Label as published.
distributor string|null
Null when no service publishes one. Check enriched_at before treating null as absence.
copyright string|null
Copyright line as published.
artwork_url string|null
Hosted by the service. Not guaranteed to stay valid.
credited_artists string[]
Every credited name, including features. Empty when the service publishes none.
recordings object[]
Per track: title and isrc. Empty when none were resolved.
matches_known_distributor boolean
True when distributor appears in the artist's known_distributors.
listings object[]
One per service the release is live on.
listings[].service string
spotify, apple_music, deezer, tidal, amazon_music, youtube_music, soundcloud, anghami, audiomack, awa, iheartradio, kkbox, line_music, netease, qq_music, seven_digital, telmore_musik, yousee_musik.
listings[].service_name string
Display name for the service.
listings[].url string
The release on that service.
listings[].distributor string|null
Who delivered it to this service. Can differ per service for one release.
listings[].first_seen_at string
ISO 8601.
listings[].removed_at string|null
Set once a scan no longer finds it. Null while live.
listings[].needs_answer boolean
True while this delivery is unacknowledged and still live.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_4813
ArtistSentry-Topic: detection.answered
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_4813",
  "type": "detection.answered",
  "api_version": "v1",
  "occurred_at": "2026-08-17T09:31:02+00:00",
  "data": {
        "id": 991,
        "title": "Cold Harbour",
        "type": "single",
        "status": "disputed",
        "needs_answer": false,
        "artist": { "id": 12, "name": "Corrin Ashe" },
        "released_on": "2026-08-14",
        "first_seen_at": "2026-08-17T04:12:09+00:00",
        "enriched_at": "2026-08-17T04:12:11+00:00",
        "upc": "0198004119353",
        "isrc": "GBKQU2680114",
        "label": "Fairlane Digital",
        "distributor": "The state51 Conspiracy",
        "copyright": "© 2026 Corrin Ashe",
        "artwork_url": "https://i.scdn.co/image/ab67616d0000b273…",
        "credited_artists": ["Corrin Ashe"],
        "recordings": [{ "title": "Cold Harbour", "isrc": "GBKQU2680114" }],
        "matches_known_distributor": false,
        "listings": [
          {
            "service": "spotify",
            "service_name": "Spotify",
            "url": "https://open.spotify.com/album/2xQ1mR…",
            "distributor": "The state51 Conspiracy",
            "first_seen_at": "2026-08-17T04:12:09+00:00",
            "removed_at": null,
            "needs_answer": false
          }
        ]
      }
}
POST release.removed

Confirms a takedown landed.

Raised when A scan finds a disputed release gone from every known service.
  • Derived from a scan rather than reported by a service, so it lags the actual removal by up to one scan interval.
  • Only disputed releases reach this state. A confirmed release disappearing is not reported.
id integer
Release id. Stable across every event about this release, and the id for /v1/detections/{id}.
title string
As the service reports it.
type string
album, single, ep, compilation, appears_on.
status string
Always removed.
needs_answer boolean
Always false.
artist object
id and name of the artist the release was found under.
released_on string|null
YYYY-MM-DD. Services disagree by a day or so, so do not key on it.
first_seen_at string
ISO 8601. First detection, not the release date.
enriched_at string|null
When provenance was last resolved. Null when the lookup has not succeeded, which separates "no distributor published" from "not looked up yet".
upc string|null
Barcode. The same release carries different barcodes on different services, so do not key on it.
isrc string|null
Track level. On a multi-track release this is the lead recording; use recordings[] for the rest.
label string|null
Label as published.
distributor string|null
Null when no service publishes one. Check enriched_at before treating null as absence.
copyright string|null
Copyright line as published.
artwork_url string|null
Hosted by the service. Not guaranteed to stay valid.
credited_artists string[]
Every credited name, including features. Empty when the service publishes none.
recordings object[]
Per track: title and isrc. Empty when none were resolved.
matches_known_distributor boolean
True when distributor appears in the artist's known_distributors.
listings object[]
One per service the release is live on.
listings[].service string
spotify, apple_music, deezer, tidal, amazon_music, youtube_music, soundcloud, anghami, audiomack, awa, iheartradio, kkbox, line_music, netease, qq_music, seven_digital, telmore_musik, yousee_musik.
listings[].service_name string
Display name for the service.
listings[].url string
The release on that service.
listings[].distributor string|null
Who delivered it to this service. Can differ per service for one release.
listings[].first_seen_at string
ISO 8601.
listings[].removed_at string
ISO 8601. Non-null on every listing, which is what makes the release removed.
listings[].needs_answer boolean
True while this delivery is unacknowledged and still live.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_5104
ArtistSentry-Topic: release.removed
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_5104",
  "type": "release.removed",
  "api_version": "v1",
  "occurred_at": "2026-08-29T03:14:55+00:00",
  "data": {
        "id": 991,
        "title": "Cold Harbour",
        "type": "single",
        "status": "removed",
        "needs_answer": false,
        "artist": { "id": 12, "name": "Corrin Ashe" },
        "released_on": "2026-08-14",
        "first_seen_at": "2026-08-17T04:12:09+00:00",
        "enriched_at": "2026-08-17T04:12:11+00:00",
        "upc": "0198004119353",
        "isrc": "GBKQU2680114",
        "label": "Fairlane Digital",
        "distributor": "The state51 Conspiracy",
        "copyright": "© 2026 Corrin Ashe",
        "artwork_url": "https://i.scdn.co/image/ab67616d0000b273…",
        "credited_artists": ["Corrin Ashe"],
        "recordings": [{ "title": "Cold Harbour", "isrc": "GBKQU2680114" }],
        "matches_known_distributor": false,
        "listings": [
          {
            "service": "spotify",
            "service_name": "Spotify",
            "url": "https://open.spotify.com/album/2xQ1mR…",
            "distributor": "The state51 Conspiracy",
            "first_seen_at": "2026-08-17T04:12:09+00:00",
            "removed_at": "2026-08-29T03:14:55+00:00",
            "needs_answer": false
          }
        ]
      }
}
POST delivery.detected

Ambiguous delivery, awaiting an answer.

Raised when A second distributor delivers a release the artist already has out.
  • Suppressed on baselines, on the first distributor for a release, and for distributors already in the artist's known_distributors.
  • Scoped per service. Two services naming different distributors for one release is ordinary and is not reported.
  • data matches an entry from GET /v1/deliveries.
id integer
Release listing id. Pass this to POST /v1/deliveries/{id}/explain.
service string
spotify, apple_music, deezer, tidal, amazon_music, youtube_music, soundcloud, anghami, audiomack, awa, iheartradio, kkbox, line_music, netease, qq_music, seven_digital, telmore_musik, yousee_musik.
service_name string
Display name for the service.
distributor string|null
Who made this delivery.
incumbent_distributor string|null
Who already held the release on this service. distributor and incumbent_distributor differing is what raised the question.
acknowledged_at null
Always null. The delivery is the question.
first_seen_at string
ISO 8601.
release object
id, title and artist of the release delivered.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_4877
ArtistSentry-Topic: delivery.detected
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_4877",
  "type": "delivery.detected",
  "api_version": "v1",
  "occurred_at": "2026-08-18T02:44:18+00:00",
  "data": {
        "id": 2210,
        "service": "spotify",
        "service_name": "Spotify",
        "distributor": "Symphonic Distribution",
        "incumbent_distributor": "The state51 Conspiracy",
        "acknowledged_at": null,
        "first_seen_at": "2026-08-18T02:44:18+00:00",
        "release": { "id": 991, "title": "Cold Harbour", "artist": "Corrin Ashe" }
      }
}
POST delivery.explained

Delivery accounted for.

Raised when A delivery is acknowledged as the artist's own.
  • One event per listing. A library-move answer settles every outstanding delivery from that distributor, so one answer can raise hundreds.
  • Also raised when confirming a detection settles the deliveries still open against it.
  • Disputing settles those deliveries too but raises nothing here, since a disputed delivery is not the artist's own. Read them off the detection.answered payload, where listings[].needs_answer is false.
  • Never batched. Throughput is held by the endpoint rate limit.
id integer
Release listing id. Pass this to POST /v1/deliveries/{id}/explain.
service string
spotify, apple_music, deezer, tidal, amazon_music, youtube_music, soundcloud, anghami, audiomack, awa, iheartradio, kkbox, line_music, netease, qq_music, seven_digital, telmore_musik, yousee_musik.
service_name string
Display name for the service.
distributor string|null
Who made this delivery.
incumbent_distributor string|null
Who already held the release on this service. distributor and incumbent_distributor differing is what raised the question.
acknowledged_at string
ISO 8601. Always set, since the answer is what raised this.
first_seen_at string
ISO 8601.
release object
id, title and artist of the release delivered.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_4901
ArtistSentry-Topic: delivery.explained
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_4901",
  "type": "delivery.explained",
  "api_version": "v1",
  "occurred_at": "2026-08-18T10:02:41+00:00",
  "data": {
        "id": 2210,
        "service": "spotify",
        "service_name": "Spotify",
        "distributor": "Symphonic Distribution",
        "incumbent_distributor": "The state51 Conspiracy",
        "acknowledged_at": "2026-08-18T10:02:41+00:00",
        "first_seen_at": "2026-08-18T02:44:18+00:00",
        "release": { "id": 991, "title": "Cold Harbour", "artist": "Corrin Ashe" }
      }
}
POST dispute.opened

Evidence pack and contact route for one service.

Raised when A release is disputed.
  • One per live service. A release on nine services raises nine, each with its own id and recipient.
  • Nothing is sent on your behalf. You contact the service and record the outcome.
  • data matches GET /v1/disputes/{id}.
id integer
Case id. Pass this to POST /v1/disputes/{id}/contacted and /outcome.
status string
Always open.
is_open boolean
Always true.
claim_type string
misattribution, impersonation, infringement. Decides both the wording and which desk it goes to.
service string
spotify, apple_music, deezer, tidal, amazon_music, youtube_music, soundcloud, anghami, audiomack, awa, iheartradio, kkbox, line_music, netease, qq_music, seven_digital, telmore_musik, yousee_musik. One case per service.
service_name string
Display name for the service.
reference string|null
Their ticket number, once you record one via /v1/disputes/{id}/contacted.
release object
id, title, artist and distributor of the disputed release.
recipient object
Where the claim goes. Snapshotted when the case opened, so it does not move if the channel is re-pointed later.
recipient.name string|null
Name of the desk.
recipient.address string|null
An email address or a URL, depending on method.
recipient.method string|null
email, form, portal.
evidence object
The evidence pack, snapshotted when the case opened. Keys below.
evidence.captured_at string
ISO 8601. The pack is accurate as of this moment and is never refreshed.
evidence.artist object
name, plus profiles[] of dsp, url and external_id for every profile being watched.
evidence.release object
title, type, release_date, upc, recordings[], label, distributor, copyright, credited_artists[] and artwork_url.
evidence.listing object|null
The listing on this case's service: dsp, url, external_id, first_seen_at, last_seen_at. Null where the release was resolved onto the service but never enumerated there.
evidence.also_live_on object[]
dsp and url for every other service still carrying the release.
evidence.detection object
first_seen_at, reviewed_at, reviewed_by, note and appeared_after_monitoring_began. reviewed_by is null when the answer came through the API.
evidence.claim object
type, label and is_copyright_claim. is_copyright_claim is false for misattribution, which is a metadata correction rather than a takedown.
evidence.routing object
primary and fallback channel snapshots, plus unrouted. Each snapshot carries recipient_type, name, method, address, instructions, source_url and verified.
evidence.routing.unrouted boolean
True when we hold no confirmed channel for this service and claim. recipient will be null and you are finding the desk yourself.
opened_at string
ISO 8601.
contacted_at null
Always null. Nobody has been contacted yet.
closed_at null
Always null.
outcome_note string|null
Whatever you recorded via /v1/disputes/{id}/outcome.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_4820
ArtistSentry-Topic: dispute.opened
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_4820",
  "type": "dispute.opened",
  "api_version": "v1",
  "occurred_at": "2026-08-17T09:31:03+00:00",
  "data": {
        "id": 331,
        "status": "open",
        "is_open": true,
        "claim_type": "impersonation",
        "service": "spotify",
        "service_name": "Spotify",
        "reference": null,
        "release": {
          "id": 991,
          "title": "Cold Harbour",
          "artist": "Corrin Ashe",
          "distributor": "The state51 Conspiracy"
        },
        "recipient": {
          "name": "Spotify Content Policy",
          "address": "https://support.spotify.com/contact-artist-claim/",
          "method": "form"
        },
        "evidence": {
          "captured_at": "2026-08-17T09:31:03+00:00",
          "artist": {
            "name": "Corrin Ashe",
            "profiles": [
              { "dsp": "Spotify", "url": "https://open.spotify.com/artist/4kPCyOKK…", "external_id": "4kPCyOKK" }
            ]
          },
          "release": {
            "title": "Cold Harbour",
            "type": "Single",
            "release_date": "2026-08-14",
            "upc": "0198004119353",
            "recordings": [{ "title": "Cold Harbour", "isrc": "GBKQU2680114" }],
            "label": "Fairlane Digital",
            "distributor": "The state51 Conspiracy",
            "copyright": "© 2026 Corrin Ashe",
            "credited_artists": ["Corrin Ashe"],
            "artwork_url": "https://i.scdn.co/image/ab67616d0000b273…"
          },
          "listing": {
            "dsp": "Spotify",
            "url": "https://open.spotify.com/album/2xQ1mR…",
            "external_id": "2xQ1mR",
            "first_seen_at": "2026-08-17T04:12:09+00:00",
            "last_seen_at": "2026-08-17T09:02:44+00:00"
          },
          "also_live_on": [
            { "dsp": "Apple Music", "url": "https://music.apple.com/gb/album/1799…" }
          ],
          "detection": {
            "first_seen_at": "2026-08-17T04:12:09+00:00",
            "reviewed_at": "2026-08-17T09:31:02+00:00",
            "reviewed_by": "Dana Vos",
            "note": "Not ours, never licensed this.",
            "appeared_after_monitoring_began": true
          },
          "claim": {
            "type": "impersonation",
            "label": "Someone releasing under my name",
            "is_copyright_claim": false
          },
          "routing": {
            "primary": {
              "recipient_type": "service",
              "recipient_type_label": "Streaming service",
              "name": "Spotify Content Policy",
              "method": "form",
              "method_label": "Web form",
              "address": "https://support.spotify.com/contact-artist-claim/",
              "instructions": "Use the artist impersonation form.",
              "source_url": "https://support.spotify.com/article/impersonation/",
              "verified": true
            },
            "fallback": null,
            "unrouted": false
          }
        },
        "opened_at": "2026-08-17T09:31:03+00:00",
        "contacted_at": null,
        "closed_at": null,
        "outcome_note": null
      }
}
POST dispute.updated

Existing case moved. Carries current state.

Raised when A case is marked contacted, closed, or re-pointed after a claim change.
  • Read is_open rather than comparing status strings if you only need open or closed.
id integer
Case id. Pass this to POST /v1/disputes/{id}/contacted and /outcome.
status string
contacted, resolved, refused or dropped. Also open, when a case was re-pointed because its claim changed.
is_open boolean
False for resolved, refused and dropped.
claim_type string
misattribution, impersonation, infringement. Decides both the wording and which desk it goes to.
service string
spotify, apple_music, deezer, tidal, amazon_music, youtube_music, soundcloud, anghami, audiomack, awa, iheartradio, kkbox, line_music, netease, qq_music, seven_digital, telmore_musik, yousee_musik. One case per service.
service_name string
Display name for the service.
reference string|null
Their ticket number, once you record one via /v1/disputes/{id}/contacted.
release object
id, title, artist and distributor of the disputed release.
recipient object
Where the claim goes. Snapshotted when the case opened, so it does not move if the channel is re-pointed later.
recipient.name string|null
Name of the desk.
recipient.address string|null
An email address or a URL, depending on method.
recipient.method string|null
email, form, portal.
evidence object
The evidence pack, snapshotted when the case opened. Keys below.
evidence.captured_at string
ISO 8601. The pack is accurate as of this moment and is never refreshed.
evidence.artist object
name, plus profiles[] of dsp, url and external_id for every profile being watched.
evidence.release object
title, type, release_date, upc, recordings[], label, distributor, copyright, credited_artists[] and artwork_url.
evidence.listing object|null
The listing on this case's service: dsp, url, external_id, first_seen_at, last_seen_at. Null where the release was resolved onto the service but never enumerated there.
evidence.also_live_on object[]
dsp and url for every other service still carrying the release.
evidence.detection object
first_seen_at, reviewed_at, reviewed_by, note and appeared_after_monitoring_began. reviewed_by is null when the answer came through the API.
evidence.claim object
type, label and is_copyright_claim. is_copyright_claim is false for misattribution, which is a metadata correction rather than a takedown.
evidence.routing object
primary and fallback channel snapshots, plus unrouted. Each snapshot carries recipient_type, name, method, address, instructions, source_url and verified.
evidence.routing.unrouted boolean
True when we hold no confirmed channel for this service and claim. recipient will be null and you are finding the desk yourself.
opened_at string
ISO 8601.
contacted_at string|null
Set when you record that you got in touch.
closed_at string|null
Set when status reaches an outcome.
outcome_note string|null
Whatever you recorded via /v1/disputes/{id}/outcome.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_5099
ArtistSentry-Topic: dispute.updated
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_5099",
  "type": "dispute.updated",
  "api_version": "v1",
  "occurred_at": "2026-08-28T16:20:07+00:00",
  "data": {
        "id": 331,
        "status": "resolved",
        "is_open": false,
        "claim_type": "impersonation",
        "service": "spotify",
        "service_name": "Spotify",
        "reference": "TICKET-4821",
        "release": {
          "id": 991,
          "title": "Cold Harbour",
          "artist": "Corrin Ashe",
          "distributor": "The state51 Conspiracy"
        },
        "recipient": {
          "name": "Spotify Content Policy",
          "address": "https://support.spotify.com/contact-artist-claim/",
          "method": "form"
        },
        "evidence": {
          "captured_at": "2026-08-17T09:31:03+00:00",
          "artist": {
            "name": "Corrin Ashe",
            "profiles": [
              { "dsp": "Spotify", "url": "https://open.spotify.com/artist/4kPCyOKK…", "external_id": "4kPCyOKK" }
            ]
          },
          "release": {
            "title": "Cold Harbour",
            "type": "Single",
            "release_date": "2026-08-14",
            "upc": "0198004119353",
            "recordings": [{ "title": "Cold Harbour", "isrc": "GBKQU2680114" }],
            "label": "Fairlane Digital",
            "distributor": "The state51 Conspiracy",
            "copyright": "© 2026 Corrin Ashe",
            "credited_artists": ["Corrin Ashe"],
            "artwork_url": "https://i.scdn.co/image/ab67616d0000b273…"
          },
          "listing": {
            "dsp": "Spotify",
            "url": "https://open.spotify.com/album/2xQ1mR…",
            "external_id": "2xQ1mR",
            "first_seen_at": "2026-08-17T04:12:09+00:00",
            "last_seen_at": "2026-08-17T09:02:44+00:00"
          },
          "also_live_on": [
            { "dsp": "Apple Music", "url": "https://music.apple.com/gb/album/1799…" }
          ],
          "detection": {
            "first_seen_at": "2026-08-17T04:12:09+00:00",
            "reviewed_at": "2026-08-17T09:31:02+00:00",
            "reviewed_by": "Dana Vos",
            "note": "Not ours, never licensed this.",
            "appeared_after_monitoring_began": true
          },
          "claim": {
            "type": "impersonation",
            "label": "Someone releasing under my name",
            "is_copyright_claim": false
          },
          "routing": {
            "primary": {
              "recipient_type": "service",
              "recipient_type_label": "Streaming service",
              "name": "Spotify Content Policy",
              "method": "form",
              "method_label": "Web form",
              "address": "https://support.spotify.com/contact-artist-claim/",
              "instructions": "Use the artist impersonation form.",
              "source_url": "https://support.spotify.com/article/impersonation/",
              "verified": true
            },
            "fallback": null,
            "unrouted": false
          }
        },
        "opened_at": "2026-08-17T09:31:03+00:00",
        "contacted_at": "2026-08-19T08:15:00+00:00",
        "closed_at": "2026-08-28T16:20:07+00:00",
        "outcome_note": "Re-tagged at source."
      }
}
POST artist.import.completed

Replaces polling GET /v1/artists/imports/{id}.

Raised when A bulk import finishes.
  • status is completed or failed. queued and running never arrive here, since the event fires once the import stops.
  • A failed import keeps whatever it created. Read added rather than assuming nothing happened.
  • artists is the number submitted. Compare it against the lengths of added and failed to find partial runs.
  • data matches GET /v1/artists/imports/{id}.
id integer
Import id.
status string
completed or failed. An import only raises this event once it stops.
artists integer
How many entries were submitted. Compare against added and failed lengths.
status_url string
GET /v1/artists/imports/{id}, same payload.
added object[]
The artists created, as full artist objects with their profiles.
failed object[]
Per entry: name, url and reason. Reasons are human readable strings, not codes.
completed_at string|null
ISO 8601.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_4790
ArtistSentry-Topic: artist.import.completed
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_4790",
  "type": "artist.import.completed",
  "api_version": "v1",
  "occurred_at": "2026-08-16T22:08:30+00:00",
  "data": {
        "id": 57,
        "status": "completed",
        "artists": 120,
        "status_url": "https://artistsentry.com/api/v1/artists/imports/57",
        "added": [
          {
            "id": 12,
            "name": "Corrin Ashe",
            "slug": "corrin-ashe",
            "monitoring_enabled": true,
            "known_distributors": [],
            "baselined_at": null,
            "profiles": [
              {
                "id": 41,
                "service": "spotify",
                "service_name": "Spotify",
                "url": "https://open.spotify.com/artist/4kPCyOKK…",
                "watched": true,
                "last_scanned_at": null,
                "needs_channel_choice": false,
                "channel_candidates": []
              }
            ]
          }
        ],
        "failed": [
          { "name": "Unknown Act", "url": "https://open.spotify.com/artist/bad", "reason": "No such artist on Spotify." }
        ],
        "completed_at": "2026-08-16T22:08:30+00:00"
      }
}
POST ping

Check your signature verification before going live.

Raised when You press Send test in the console.
  • Not subscribable. Sending a test ignores the subscription list.
  • Never raised by the product, so it will not arrive unprompted.
  • Signed and retried exactly like every other topic.
message string
Fixed string.
endpoint string
The endpoint name from your console.
Request
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ArtistSentry-Webhooks/1.0
ArtistSentry-Event-Id: evt_5310
ArtistSentry-Topic: ping
ArtistSentry-Attempt: 1
ArtistSentry-Signature: t=1755455400,v1=8f2a…c91d

{
  "id": "evt_5310",
  "type": "ping",
  "api_version": "v1",
  "occurred_at": "2026-08-17T18:49:47+00:00",
  "data": {
        "message": "This is a test from your ArtistSentry console.",
        "endpoint": "Catalogue pipeline"
      }
}