AMFTF Member API
Screen your catalogue against AMFTF's documented music-fraud records — fake and shell labels, stolen or re-delivered releases, AI-generated catalogues, impersonating artists and TikTok-store-only infringements — and act on matches inside your own workflow.
Your catalogue stays with you. Screening runs in one direction: you query AMFTF. We do not store, log or retain the identifiers you send — only request counts, for rate limiting. For catalogue-wide sweeps there is a daily bulk file you run locally, so millions of tracks can be checked without a single identifier reaching us.
Introduction
The Anti Music Fraud Task Force (AMFTF) documents music-distribution fraud: shell labels registered to publish other people's recordings, releases re-delivered under invented artist names, AI-generated catalogue published at volume, and sounds delivered to the TikTok store only, where they are invisible to any check that starts from a streaming service.
This API exists so that a distributor can find that material in its own catalogue — before it earns, before a rights holder complains, and without either side handing over data it should not have to.
Screen at ingest
Check a new delivery against documented records and the pre-delivery watchlist in one call.
Sweep the catalogue
Download a daily list — or a 117 KB filter — and screen millions of tracks locally.
Act with evidence
Every record carries a case id, an evidence link, and where audio analysis was used, a probability.
How screening works
There are two paths. Most members use both.
| Path | Use when | Load on you | Load on AMFTF |
|---|---|---|---|
| A. Bulk file, screened locally /v1/screening-list | Whole catalogue (millions of tracks) | One download per day | None |
| B. Batch check /v1/check | Confirming hits · new deliveries · ad-hoc lookups | One request per 1,000 ids | Small |
Recommended shape: A for the nightly sweep, B to confirm the hits it produces and to screen new deliveries at ingest. The bulk filter is probabilistic, so every hit from path A should be confirmed through path B before anyone acts on it.
Sandbox & live
Every response carries a mode field, so you always know which dataset answered.
| Mode | Data | Signing |
|---|---|---|
sandbox | Entirely synthetic. Invented ISRCs (XX9AA…), UPCs, label names (EXAMPLE-SHELL-LLC) and ids. Nothing here is a real case and nothing is an allegation about any artist, label or distributor. | May be relaxed on request for first contact |
live | Documented AMFTF case records. | Always required |
Build against sandbox first. The shapes are identical, so nothing changes when you move to a live key — but a sandbox key cannot leak a real case, and no real identifier ever enters your logs, tickets or test fixtures during integration. That is the point of it.
Base URL & versioning
TLS 1.2 or 1.3 only. HTTP is redirected to HTTPS; plain HTTP is never served.
- Path-versioned. Breaking changes get a new path (
/v2), never a silent change under/v1. - Additive within a version. New fields may appear at any time — parse defensively and ignore what you do not recognise.
- 90 days' written notice before any breaking change, to every member with an active key.
Get a key
Keys are issued per member. Write to sher@pumpa.org.pk with your company name and a technical contact; sandbox keys are issued the same day.
You receive two values:
| Value | Looks like | Used for |
|---|---|---|
key | amftf_sandbox_… / amftf_live_… | Sent in the X-AMFTF-Key header. Identifies you. |
secret | random string | Never sent. Used to sign requests and to verify bulk files. |
The secret is never transmitted. It signs requests; it does not travel in them. We send key and secret through separate channels and ask you to store the secret in a secrets manager, not in source control. If either is exposed, tell us and we rotate — rotation takes minutes and old keys can be disabled without downtime.
Your first request
The status endpoint needs no key. Start here to confirm connectivity:
curl -s https://api.amftf.org/v1/health {"ok":true,"mode":"sandbox","list_version":"2026-08-07", "generated_at":"2026-08-07T00:00:00Z"}
Then a real screening call. This example uses a sandbox identifier, so you can run it verbatim once you have a sandbox key:
KEY="amftf_sandbox_…" SECRET="…" BODY='{"isrc":["XX9AA2600100"]}' TS=$(date +%s) SIG=$(printf '%s.%s' "$TS" "$BODY" \ | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //') curl -s https://api.amftf.org/v1/check \ -H "X-AMFTF-Key: $KEY" \ -H "X-AMFTF-Timestamp: $TS" \ -H "X-AMFTF-Signature: sha256=$SIG" \ -H 'Content-Type: application/json' \ -d "$BODY"
import hashlib, hmac, json, os, time, urllib.request BASE = "https://api.amftf.org" KEY = os.environ["AMFTF_KEY"] SECRET = os.environ["AMFTF_SECRET"] def call(method, path, payload=None): # Sign the EXACT bytes you send — re-serialising later breaks the signature. body = json.dumps(payload).encode() if payload is not None else b"" ts = str(int(time.time())) sig = "sha256=" + hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest() headers = {"X-AMFTF-Key": KEY, "X-AMFTF-Timestamp": ts, "X-AMFTF-Signature": sig} if body: headers["Content-Type"] = "application/json" req = urllib.request.Request(BASE + path, data=body or None, method=method, headers=headers) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read()) print(call("POST", "/v1/check", {"isrc": ["XX9AA2600100"]}))
import crypto from "node:crypto"; const BASE = "https://api.amftf.org"; const KEY = process.env.AMFTF_KEY; const SECRET = process.env.AMFTF_SECRET; async function call(method, path, payload) { // Sign the EXACT string you send as the body. const body = payload === undefined ? "" : JSON.stringify(payload); const ts = Math.floor(Date.now() / 1000).toString(); const sig = "sha256=" + crypto .createHmac("sha256", SECRET) .update(ts + "." + body) .digest("hex"); const res = await fetch(BASE + path, { method, headers: { "X-AMFTF-Key": KEY, "X-AMFTF-Timestamp": ts, "X-AMFTF-Signature": sig, ...(body ? { "Content-Type": "application/json" } : {}), }, body: body || undefined, }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); } console.log(await call("POST", "/v1/check", { isrc: ["XX9AA2600100"] }));
Authentication
Three headers on every authenticated request:
| Header | Value |
|---|---|
X-AMFTF-Key | Your key. Required. |
X-AMFTF-Timestamp | Unix seconds. Must be within 300 seconds of our clock. |
X-AMFTF-Signature | sha256=<hex> — see below. |
Optionally, a key can be locked to an IP allow-list. Tell us the egress addresses and we
restrict the key to them; a request from anywhere else is refused with 403
ip_not_allowed, even with the correct signature.
Request signing
signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + raw_body) # raw_body is the exact bytes of the request body. # For GET requests, raw_body is empty — sign timestamp + "." only.
Two mistakes account for nearly every signature failure, so they are worth stating plainly:
- Re-serialising the body after signing. If your HTTP client re-encodes JSON — different key order, different whitespace — the bytes on the wire no longer match what you signed. Serialise once, sign those bytes, send those bytes.
- A drifting clock. The 300-second window exists to stop a captured request
being replayed later. If your server's clock is off by more than five minutes, every request fails
with
stale_timestamp. Run NTP.
The signature covers the body, so a proxy that modifies the payload in transit invalidates it — which is the intended behaviour, not an inconvenience.
Auth failures
| Status | Code | Meaning |
|---|---|---|
| 401 | no_key | X-AMFTF-Key header missing. |
| 401 | bad_key | Key not recognised, or disabled. |
| 401 | signature_required | This key requires signing; timestamp or signature absent. |
| 401 | bad_timestamp | Timestamp is not unix seconds. |
| 401 | stale_timestamp | Outside the 300-second window (replay protection). |
| 401 | bad_signature | Signature does not match the body received. |
| 403 | ip_not_allowed | Request IP is not on this key's allow-list. |
We deliberately do not distinguish "unknown key" from "disabled key" in the response — an attacker should not learn which keys exist.
Identifier normalisation
Both sides must normalise identically. If they don't, screening does not error — it quietly misses, which is worse. These are the exact rules we apply, on our side and in the bulk filter:
| Type | Rule | Example |
|---|---|---|
| ISRC, Spotify ids, TikTok sound id | Upper-case; keep A–Z0–9 only | xx-9aa-26-00100 → XX9AA2600100 |
| UPC / EAN / GTIN | Digits only, left-pad to 14 (GTIN-14) | 0000000000010 → 00000000000010 |
| UPC — rejected | Fewer than 8 digits, or all zeros → not an identifier, ignored | 0000000000000 → ignored |
| Names (advisory only) | NFKD, strip diacritics, lower-case, non-alphanumerics → single spaces | Éxemple Artist! → exemple artist |
The UPC rule matters more than it looks. The same release is written as UPC-12, EAN-13 and GTIN-14 across systems; without a canonical form one release looks like three different products and a real match slips through.
Exact vs advisory matches
Every match carries a match field. Treat the two kinds differently — this is the
single most important thing to get right in your integration.
match | Based on | How to use it |
|---|---|---|
exact | ISRC · UPC · Spotify album/artist id · TikTok sound id | Safe to route straight into your takedown or hold queue. |
advisory | Artist name · title+artist pair · name similarity | Human review required. Also carries similarity (0–1) and review. |
Do not auto-action advisory matches. Names collide legitimately — two unrelated artists can share one, and transliteration produces near-identical spellings for entirely different people. Names are supplied so a reviewer can compare in seconds, not so a script can act. We would much rather you tell us a name match was wrong than have a legitimate artist suspended over it.
The similarity floor is 0.86; below that we do not report a name match at all. That threshold is a judgement, not a law of nature: lower, and the noise makes reviewers stop looking; a list nobody reads is worthless.
Findings & status
Two fields, because they answer different questions: what AMFTF has determined, and what has happened since.
amftf_finding — our determination
| Value | Meaning |
|---|---|
documented | AMFTF has investigated this release and assessed it as fraudulent. Evidence is on file and a case is open. This is a conclusion, not an open question. |
status — where the case stands with the distributor
| Value | Meaning |
|---|---|
suspected | Documented by AMFTF and awaiting the distributor's own verification. It is on your side to check and act — not on ours to decide. |
verified | The distributor or platform checked it and agrees with our finding. |
taken_down | Removed following an AMFTF report. |
dismissed | Evidence cleared it. We retire the flag and keep the record so the same release is not re-reported. |
How to read suspected. It marks a case AMFTF considers proven on the
evidence we hold, handed to you for verification against your own rights and delivery records —
things only you can see. The conservative wording is deliberate: AMFTF does not make legal
determinations about third parties, and a release can have an explanation that exists only in your
contracts. The word is careful; the finding behind it is not tentative.
Every record carries case and evidence, and where audio analysis was
used, ai_likelihood — so your reviewer sees the basis, not just a flag.
Distributor disclosure
This API never returns a distributor name. Every record carries an object instead:
{"known": true, "disclosed": false} // we hold it, we don't publish it
{"known": false, "reason": "tiktok_store_only"} // we do not know it
{"known": false, "reason": "not_recorded"} // absence, stated as absence
| Case | Value | Why |
|---|---|---|
| Release visible on a streaming service | known: truedisclosed: false | We hold delivery metadata, but naming the distributor of a release means disclosing one member's data to another. Cross-distributor matters are handled by AMFTF directly. |
| TikTok-store-only delivery | known: falsereason: tiktok_store_only | Not published anywhere we can see it. We do not infer it. |
| Streaming release, metadata gave us nothing | known: falsereason: not_recorded | Absence, stated as absence. |
known: false means we do not know. It never means "no distributor" or "an
unidentified bad distributor". We would rather hand you a gap than a guess: a wrong distributor
attribution inside a fraud dataset damages a company that did nothing, and it would end this
association's usefulness the first time it happened.
You do not need this field to act. If an identifier we return matches your catalogue, it is yours to review; if it doesn't, it isn't your problem to solve. That test is more reliable than anything we could supply.
Data retention
- Identifiers submitted to
/v1/checkare matched in memory and discarded. They are not written to disk, not logged, and not used to build any profile of your catalogue. - Access logs record method, path, status, response size and duration. Identifiers travel in request bodies only — this API never accepts them in a query string, precisely so they cannot end up in a log line, a proxy cache, or a browser history.
- We retain per-key request counts and timestamps, for rate limiting.
- Nothing a member submits is shared with another member, in any form.
- The service reads a read-only snapshot of AMFTF case data. It holds no credentials for, and no route to, any AMFTF database.
Batch check
Screen up to 1,000 identifiers per request. Accepts several identifier types in one call. Non-matching identifiers are absent from the response — and are not retained.
Request body
| Field | Type | Match |
|---|---|---|
isrc | string[] | exact |
upc | string[] | exact |
spotify_album_id | string[] | exact |
spotify_artist_id | string[] | exact |
tiktok_sound_id | string[] | exact |
artist_name | string[] | advisory |
pairs | {title, artist}[] | advisory |
All fields are optional; supply at least one. The 1,000 limit counts every element across every
field, including pairs.
{
"isrc": ["XX9AA2600100", "XX9AA2600110"],
"upc": ["0000000000010"],
"spotify_artist_id": ["SANDBOXARTIST000000001"],
"tiktok_sound_id": ["9000000000000000001"],
"artist_name": ["Example Persona"],
"pairs": [{ "title": "Example Title", "artist": "Example Persona" }]
}
{
"checked": { "isrc": 2, "upc": 1, "tiktok_sound_id": 1 },
"matches": [
{
"isrc": "XX9AA2600100",
"upc": "0000000000010",
"match": "exact",
"status": "suspected",
"amftf_finding": "documented",
"kind": "ai_generated_catalogue",
"label": "EXAMPLE-SHELL-LLC",
"release": "Example Release 1-1",
"artist": "Example Persona",
"spotify_album": "https://open.spotify.com/album/SANDBOXALBUM0000000010",
"release_date": "2026-06-01",
"upload_date": "2026-07-01",
"backdated": true,
"ai_likelihood": 87.4,
"tiktok_creations": 12345,
"distributor": { "known": true, "disclosed": false },
"case": "AMFTF-EXAMPLE-0001",
"evidence": "https://members.amftf.org/case/AMFTF-EXAMPLE-0001",
"first_seen": "2026-06-11",
"updated_at": "2026-08-01"
},
{
"tiktok_sound_id": "9000000000000000001",
"tiktok_url": "https://www.tiktok.com/music/x-9000000000000000001",
"delivery_type": "redelivery",
"match": "exact",
"delivered_as": { "title": "Example Title", "artist": "Example Persona" },
"original": { "title": "Example Title", "artist": "Example Original Artist",
"rightsholder": "Example Rightsholder" },
"creations": 12345,
"distributor": { "known": false, "reason": "tiktok_store_only" },
"status": "suspected",
"amftf_finding": "documented",
"case": "AMFTF-EXAMPLE-0002"
},
{
"queried_name": "Example Persona",
"artist": "Example Persona",
"spotify_artist_id": "SANDBOXARTIST000000001",
"kind": "ai_persona",
"match": "advisory",
"similarity": 1.0,
"review": "Human review required — name-based match",
"distributor": { "known": true, "disclosed": false },
"status": "suspected",
"case": "AMFTF-EXAMPLE-0001"
}
],
"list_version": "2026-08-07",
"mode": "sandbox",
"note": "Non-matching identifiers are absent from this response and are not retained."
}
Response fields
| Field | Type | Notes |
|---|---|---|
checked | object | How many identifiers we received, per field. Use it to confirm nothing was dropped in transit. |
matches[] | array | Empty array when nothing matched. Absence of a match is not proof of legitimacy — only that it is not in our records. |
match | string | exact or advisory. See above. |
similarity | number | 0–1. Advisory matches only. |
kind | string | See fraud kinds. |
backdated | bool | Release date differs from upload date — the date was presented as earlier than it is. Evidence-grade. |
ai_likelihood | number | 0–100. An audio-analysis probability, not a legal determination. |
tiktok_creations / creations | number | Video creations. Estimated — see the note under fraud kinds. |
distributor | object | Never a name. See distributor disclosure. |
case, evidence | string | Case id and the evidence record behind it. |
delivery_type | string | TikTok records only: redelivery or new_delivery. |
Screening list
The full documented set as a downloadable file — dated, signed, and cheap to serve. Regenerated daily.
| Parameter | Values | Notes |
|---|---|---|
format | jsonl (default) · csv · bloom | |
since | YYYY-MM-DD | Only records with updated_at >= since. Incremental sync. Ignored for bloom. |
Response headers
| Header | Meaning |
|---|---|
x-amftf-list-version | Stable, quotable date of this list. Record it if you need to show, for compliance, which version a catalogue was screened against. |
x-amftf-list-signature | sha256=<hex> — HMAC over the body, using your own secret. Verify it: that proves the file is ours and was not altered in transit. |
x-amftf-signature-basis | The exact formula, so you never have to guess: HMAC_SHA256(your_secret, list_version + '.' + body) |
GET /v1/screening-list?format=jsonl&since=2026-08-01 {"isrc":"XX9AA2600100","upc":"0000000000010","status":"suspected","amftf_finding":"documented","kind":"ai_generated_catalogue","case":"AMFTF-EXAMPLE-0001","updated_at":"2026-08-01"} {"isrc":null,"upc":"0000000000024","status":"taken_down","amftf_finding":"documented","kind":"fake_label_release","case":"AMFTF-EXAMPLE-0002","updated_at":"2026-08-02"} {"isrc":"XX9AA2690001","upc":"0000000090001","tiktok_sound_id":"9000000000000000001","status":"suspected","kind":"tiktok_only_redelivery","case":"AMFTF-EXAMPLE-0002","updated_at":"2026-08-05"}
GET /v1/screening-list?format=csv
isrc,upc,tiktok_sound_id,status,amftf_finding,kind,case,updated_at
XX9AA2600100,0000000000010,,suspected,documented,ai_generated_catalogue,AMFTF-EXAMPLE-0001,2026-08-01
,0000000000024,,taken_down,documented,fake_label_release,AMFTF-EXAMPLE-0002,2026-08-02
XX9AA2690001,0000000090001,9000000000000000001,suspected,documented,tiktok_only_redelivery,AMFTF-EXAMPLE-0002,2026-08-05
import hashlib, hmac # Verify the file really is ours and arrived unmodified. version = resp.headers["x-amftf-list-version"] claimed = resp.headers["x-amftf-list-signature"] expected = "sha256=" + hmac.new( SECRET.encode(), version.encode() + b"." + body, # body = raw bytes as received hashlib.sha256, ).hexdigest() assert hmac.compare_digest(expected, claimed), "list signature mismatch"
Use compare_digest (or your language's constant-time equivalent) rather than
== when comparing signatures.
Watchlist
Pre-delivery screening: catch it before ingest rather than after it is live and earning. This is the highest-value endpoint for a distributor, and the cheapest to wire in.
{
"list_version": "2026-08-07",
"label_names": ["EXAMPLE-SHELL-LLC", "EXAMPLE-HOLDINGS-LLC", …],
"artist_names": ["Example Persona", "Exemple Artist", …],
"isrc_registrants": ["XX9AA", "XX8BB", …],
"upc_prefixes": ["0000000", "0000001", …],
"note": "Prefix hits are the strongest signal in the dataset…"
}
| Field | Match strength | How to use it |
|---|---|---|
isrc_registrantsupc_prefixes | Strong | Differently-named shell labels sharing one ISRC registrant or GS1 prefix are usually one operator behind several masks. A prefix hit on a new delivery is worth a manual look even when nothing else matches. |
label_namesartist_names | Advisory | Human review. Same caution as any name match. |
Prefixes are not proof on their own — a registrant prefix can be reassigned, and legitimate labels sometimes share an issuer. They are the best early signal we have, which is a different claim from certainty.
Status
Public — no key required. Safe to poll from a monitor. Returns which dataset is loaded and its version, and nothing about any case.
{
"ok": true,
"mode": "sandbox",
"list_version": "2026-08-07",
"generated_at": "2026-08-07T00:00:00Z"
}
Endpoint index
Public. A machine-readable summary of the live endpoints, the auth scheme, the normalisation rules and the retention policy — the same facts as this page, in JSON, so your client can assert against them rather than trusting a document that may be out of date.
Bloom filter format
GET /v1/screening-list?format=bloom returns a compact probabilistic filter:
roughly 117 KB per 100,000 entries at a 1% false-positive rate.
Millions of tracks can be screened in memory on your own hardware without a single identifier
reaching us. Every hit is then confirmed through /v1/check,
where false positives disappear.
File layout
line 1 JSON header, then a newline (\n) rest ceil(m / 8) raw bytes // header {"algo":"bloom-v1","m":958500,"k":7,"hash":"sha256-double", "bit_order":"lsb-first","n":100000,"list_version":"2026-08-07"}
Algorithm
Documented in full so you can reimplement it in any language and not depend on a library of ours:
m = header.m # bit count k = header.k # 7 h = SHA256(normalised_identifier) # 32 bytes; see Normalisation a = big_endian_uint64(h[0:8]) b = big_endian_uint64(h[8:16]) for i in 0..k-1: bit = (a + i*b) mod m test bits[bit >> 3] & (1 << (bit & 7)) # LSB first present = all k bits set # → confirm via /v1/check absent = any bit clear # → definitively not in the list
import hashlib, json def load(blob: bytes): head, _, bits = blob.partition(b"\n") return json.loads(head), bits def contains(header, bits, item: str) -> bool: m, k = header["m"], header["k"] h = hashlib.sha256(item.encode()).digest() a = int.from_bytes(h[0:8], "big") b = int.from_bytes(h[8:16], "big") return all( bits[p >> 3] >> (p & 7) & 1 for p in ((a + i * b) % m for i in range(k)) ) # hits must be confirmed with POST /v1/check — 1% are false positives header, bits = load(open("amftf.bloom", "rb").read()) hits = [x for x in my_catalogue_normalised if contains(header, bits, x)]
package screening import ( "crypto/sha256" "encoding/binary" ) type Bloom struct { M uint64 K int Bits []byte } func (bl *Bloom) Contains(item string) bool { h := sha256.Sum256([]byte(item)) // normalise first a := binary.BigEndian.Uint64(h[0:8]) b := binary.BigEndian.Uint64(h[8:16]) for i := 0; i < bl.K; i++ { p := (a + uint64(i)*b) % bl.M if bl.Bits[p>>3]&(1<<(p&7)) == 0 { return false // definitively absent } } return true // probably present — confirm via /v1/check }
Screening locally
The shape we recommend for a nightly catalogue sweep:
- Download
?format=bloom. Verifyx-amftf-list-signatureagainst your secret. Recordx-amftf-list-version. - Normalise your own identifiers using the rules above. This step is where integrations silently go wrong — test it against the sandbox values first.
- Filter in memory. A miss is definitive: that identifier is not in our records.
- Confirm the hits with
POST /v1/checkin batches of 1,000. False positives vanish here, and you get the case, evidence and status for the real ones. - Route —
exactto your action queue,advisoryto a human.
Why the filter rather than the full list? Both are offered and either works. The filter is smaller, faster to check, and — because it is one-way — cannot be read back as a list of our records. Nothing about your catalogue leaves your systems in either case.
Fraud kinds
kind | What it is |
|---|---|
fake_label_release | Released under a shell label registered to publish other people's recordings. |
impersonated_artist | A profile using a real artist's name, or a near-spelling of it, to attract their audience. |
ai_persona_artist | An invented artist with no real person behind it, used to publish AI-generated catalogue at volume. |
ai_generated_catalogue | Recordings assessed as machine-generated by audio analysis. |
metadata_backdating | Release date presented as earlier than the actual upload, typically to claim precedence over the original. |
tiktok_only_redelivery | An existing song re-delivered to the TikTok store alone under a changed artist or title, so creations pay the wrong party. |
tiktok_only_new_delivery | A track that exists only in the TikTok store — typically AI-generated or under a fake persona, with no release anywhere else. |
stream_manipulation | Artificial streaming activity documented against the release. |
TikTok-store-only deliveries are invisible to normal catalogue checks. They never appear
on Spotify or Apple, so any screening that starts from a streaming service will never see them.
That is exactly why the pattern is used. On TikTok, rights holders are paid per video
creation, not per view — so creations is the figure that matters, and the one
we report. Any revenue number we attach is an estimate and labelled as such.
Error codes
Errors are JSON, with a stable machine-readable code. Match on the code, never on
the message — messages may be reworded.
{ "error": { "code": "too_many_identifiers",
"message": "1500 identifiers in one request; the limit is 1000." } }
| Status | Code | Meaning |
|---|---|---|
| 400 | empty_body | No JSON body sent. |
| 400 | bad_json | Body is not valid JSON, or not an object. |
| 400 | bad_field | A field has the wrong type. |
| 400 | nothing_to_check | No identifiers supplied. |
| 400 | bad_format | format is not jsonl, csv or bloom. |
| 400 | bad_since | since is not YYYY-MM-DD. |
| 401 / 403 | See auth failures. | |
| 404 | not_found | Unknown endpoint. |
| 413 | too_many_identifiers | More than 1,000 identifiers in one request. |
| 413 | body_too_large | Body exceeds 2 MB. |
| 429 | rate_limited | Requests-per-minute exceeded. Carries Retry-After. |
| 429 | identifier_quota | Identifiers-per-minute exceeded. Carries Retry-After. |
On 429, wait for Retry-After seconds. Do not retry immediately with
backoff of your own devising — the header tells you exactly how long, and hammering a limit is how
a key ends up suspended.
Rate limits
| Limit | Default |
|---|---|
| Requests per minute, per key | 60 |
| Identifiers per request | 1,000 |
| Identifiers per minute, per key | 10,000 |
| Request body | 2 MB |
| Clock skew | ±300 seconds |
Higher limits on request — tell us the shape of your workload and we will raise them. The bulk file exists precisely so that a full catalogue sweep never needs to touch these limits.
Uptime target: 99.5% monthly. The service runs on AMFTF infrastructure independent of the member portal, so portal maintenance does not affect it.
Roadmap
Phase 1 is live. The rest is deliberately unbuilt: the first partners to review shape it, and building endpoints before anyone has said what their workflow needs produces the wrong endpoints.
| Phase | Scope | Status |
|---|---|---|
| 1 | POST /check · GET /screening-list · GET /watchlist · GET /health | live |
| 2 | GET /fraud/labels · /fraud/releases · /fraud/tracks · /fraud/artists · /fraud/tiktok · GET /case/{id} — cursor-paginated, updated_since incremental sync | planned |
| 3 | POST /outcomes — report what you actioned · optional webhook for newly documented matches | planned |
About POST /outcomes
The one thing we ask in return, and it stays optional. When you action a release we flagged
(removed / under_review / dismissed, with a date), it closes
the case in our records and lets us publish a distributor-responsiveness comparison that is
evidence-based rather than estimated. Nothing else from a member's submission is stored or shared.
Changelog
| Date | Change |
|---|---|
| 2026-08-07 | v1 Phase 1 live. /check, /screening-list (jsonl · csv · bloom), /watchlist, /health. Sandbox dataset is synthetic. |
| 2026-08-07 | distributor object replaces any distributor name field, across every endpoint. |
| 2026-08-07 | TikTok records split by delivery_type: redelivery and new_delivery. |
Attribution & scope
Reports and internal tooling built on this data should carry:
"Fraud intelligence provided by the Anti Music Fraud Task Force (AMFTF)."
Not exposed by this API, deliberately
- Internal licensor identifiers.
- AMFTF's prefix-network mapping and correlation graph.
- Member-submitted confidential material.
- The identity of any other distributor holding a matching release. Cross-distributor matters are handled by AMFTF directly, not disclosed member to member.
Accuracy
ai_likelihood is an audio-analysis probability, not a legal determination.
creations and any revenue figures are estimates and labelled as such. Every record
released through this API — including every suspected one — has evidence on file and a
named case behind it; nothing is published on suspicion alone.
Absence from our records is not a clean bill of health. It means the release is not in our documented set — not that it has been cleared.
Contact
Sher
AMFTF Head — Anti Music Fraud Task Force (AMFTF)
sher@pumpa.org.pk · amftf.org
For a key, an integration call, or to have a field added to v1: write, and mark up whatever in this document does not fit your workflow. That is how v1 gets finished.
If you believe a record here is wrong, tell us. A dataset like this is only useful if it is correctable, and a correction costs us far less than a wrong flag costs the company on the other end of it.