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 authenticated response carries a mode field, so you always know which dataset answered. (/v1/health is the one exception — it takes no key, so its mode describes the instance. See Status.)
| 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.
The key decides the dataset, not the deployment. A sandbox key is served the
synthetic dataset and nothing else — there is no configuration in which it starts returning live
records. If the synthetic dataset is ever unavailable on an instance, a sandbox key receives
503 sandbox_unavailable rather than falling back to live data. Failing is the correct
behaviour there; quietly upgrading a test key to real case data is not.
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 | 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 these 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 id | Safe to route straight into your takedown or hold queue. |
original | ISRC · UPC of the original release behind a redelivery | The identifier you sent is the victim, not the offender. See below. |
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 original object carries identifiers only — isrc and
upc. The original artist's and release's names are deliberately withheld: the artist
whose work was re-delivered is the injured party, not a suspect, and naming them in a fraud feed
serves no purpose. Identifiers lose nothing here — they normalise to an exact comparison, whereas
names break on transliteration and spelling. Where AMFTF knows a delivery is a redelivery but holds
no identifier for the original, original is null and
delivery_type is still "redelivery".
match: "original" means the opposite of what a fraud match usually means.
A TikTok-store-only redelivery takes somebody else's real release and delivers it again as
though it were new. The original release is indexed too, so that if it is yours you find out. When
you get this match, the record describes the redelivery — not your release. Your
release is the one being copied. Do not take your own release down.
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 |
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.
Two fields were removed in v1.7: spotify_artist_id and
tiktok_sound_id. Neither is part of the AMFTF dataset, so every query against them
returned an empty result — and an empty result reads as “clean”. Sending them now returns
400 field_removed rather than a silently empty match list. Screen artists with
artist_name, and TikTok-store-only deliveries with isrc or
upc.
TikTok-store-only deliveries frequently carry no ISRC or UPC at all. Those records are
still screenable — match them by artist_name (advisory) or by pairs
(title + artist). Every TikTok-only record's own title and artist are indexed for name matching, so
a delivery with no identifier is still caught by name. When it is a redelivery, the
original release's ISRC/UPC are indexed too.
{
"isrc": ["XX9AA2600100", "XX9AA2600110"],
"upc": ["0000000000010"],
"artist_name": ["Example Persona"],
"pairs": [{ "title": "Example Title", "artist": "Example Persona" }]
}
{
"checked": { "isrc": 2, "upc": 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,
"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"
},
{
"delivery_type": "redelivery",
"match": "exact",
"delivered_as": { "title": "Example Title", "artist": "Example Persona" },
"original": { "isrc": "XX9AA2670001", "upc": "0000000070001" },
"distributor": { "known": false, "reason": "tiktok_store_only" },
"status": "suspected",
"amftf_finding": "documented",
"case": "AMFTF-EXAMPLE-0002"
},
{
"queried_name": "Example Persona",
"artist": "Example Persona",
"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, original or advisory. See above — original means the identifier you sent is the victim of a redelivery, not the offender. |
similarity | number | 0–1. Advisory matches only. |
release, artist, label | string | The documented fraudulent release’s own title, artist and shell-label name — the offender’s, not a victim’s. |
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. |
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. |
delivered_as | object | TikTok records: {title, artist} the fake was delivered under. These are indexed for artist_name / pairs matching, so a TikTok-only delivery with no ISRC/UPC is still caught by name. |
original | object | Redeliveries: {isrc, upc} of the original release (identifiers only — the victim’s name is withheld). null if not known. |
release_date, upload_date | string | When present. A gap between them is what backdated flags. |
first_seen, updated_at | string | When AMFTF first documented the record, and when it last changed (YYYY-MM-DD). |
spotify_album | string | Convenience URL when a Spotify album id is on the record. |
Screening list
The full documented set as a downloadable file — dated, signed, and cheap to serve. The
list_version header tells you exactly which build you received; poll it and re-download
only when it changes rather than assuming a fixed schedule.
| 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","status":"suspected","amftf_finding":"documented","kind":"tiktok_only_redelivery","case":"AMFTF-EXAMPLE-0002","updated_at":"2026-08-05"}
GET /v1/screening-list?format=csv
isrc,upc,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,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": "live",
"list_version": "2026-08-10",
"generated_at": "2026-08-10T01:42:04+00:00",
"sandbox_dataset": "2026-08-07" // present when a synthetic dataset is loaded
}
On this endpoint mode describes the instance, not your key — /v1/health
takes no key, so it cannot report which dataset would answer you. A sandbox key sees
"mode":"live" here and still receives synthetic records everywhere else. To confirm which
dataset answered your request, read the mode field in that request's own response.
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. Payment on TikTok follows video creations rather than views, which is what makes the re-delivery worth doing.
What we do not send: creation counts, and stream counts. We hold both, and both are assembled from third-party sources. For your own catalogue your figures are authoritative — a weaker number from us would only move the conversation onto our data quality instead of onto the release. You get the identifiers, the case and the evidence; the counts you already have are better than ours.
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. |
| 503 | sandbox_unavailable | A sandbox key was used on an instance where the synthetic dataset is not loaded. Deliberately an error, not a fallback to live data — tell us and we restore it. |
| 429 | rate_limited | Request rate exceeded for this endpoint. Carries Retry-After. See rate limits. |
| 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 |
|---|---|
POST /v1/check — requests per minute, per key | 60 |
GET /v1/watchlist — requests per minute, per key | 120 |
GET /v1/screening-list — requests per hour, per key | 20 |
| Identifiers per request | 1,000 |
| Identifiers per minute, per key | 10,000 |
| Request body | 2 MB |
| Clock skew | ±300 seconds |
screening-list is limited per hour rather than per minute, because the list is
built once a day: re-downloading it more often gains you nothing and it is by far the most expensive
response we serve. Poll x-amftf-list-version — or /v1/health — and download
only when it changes.
Every rate-limited response carries its own budget, so you never have to guess where you stand:
| Header | Meaning |
|---|---|
X-AMFTF-RateLimit-Limit | The limit that applies to this endpoint for your key. |
X-AMFTF-RateLimit-Remaining | Requests left in the current window. |
Retry-After | On 429 only — seconds to wait before retrying. |
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 separate from the member portal, so screening is unaffected by portal maintenance — case data and your existing key keep working throughout. Issuing a new key or rotating a secret does go through the portal, so those two actions can be delayed while it is down.
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-10 | Per-endpoint rate limits. /check 60/min, /watchlist 120/min, /screening-list 20/hour — all per key. Responses now carry X-AMFTF-RateLimit-Limit and X-AMFTF-RateLimit-Remaining; 429 carries Retry-After. |
| 2026-08-10 | Documentation corrections: match lists original alongside exact and advisory; the csv example had one column more in its rows than in its header; /v1/health now states that its mode describes the instance, not your key. |
| 2026-08-15 | TikTok-only name screening. TikTok-store-only deliveries (which often have no ISRC/UPC) are now matched by artist_name and pairs — their title and artist are indexed for name matching. Previously only ISRC/UPC-bearing records were name-searchable. |
| 2026-08-07 | v1.7 — spotify_artist_id and tiktok_sound_id removed from POST /check. Both now return 400 field_removed instead of a silently empty match list. Screen artists with artist_name, TikTok-store-only deliveries with isrc or upc. |
| 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 | Stream counts and TikTok creation counts removed from every endpoint — your own figures are authoritative for your catalogue. |
| 2026-08-07 | The key's mode now selects the dataset. A sandbox key is never served live records; if the synthetic dataset is unavailable it returns 503 sandbox_unavailable. |
| 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.
- Stream counts and TikTok creation counts. Yours are authoritative for your own catalogue; ours are assembled from third-party sources. We send identifiers and evidence, not our numbers.
Accuracy
ai_likelihood is an audio-analysis probability, not a legal determination.
Stream counts and TikTok creation counts are not part of this API — see
fraud kinds. 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.