Ky

API

Ky, from your own code.

Send songs, stems and MIDI files, poll for the verdict, read what lined up. Plain HTTPS and JSON, a key in a header, no SDK.

Overview

The Ky API does what the site does. You create a check holding one or more files; each file becomes an item; an engine compares each item with the catalogue, or with your other files, for musical resemblance (melody and bass line, chords, rhythm, form and words) and gives it one of four verdicts, a one-line headline and a short explanation.

Every request goes to https://ky.ax. Requests and responses are JSON, apart from the files themselves. Times are Unix seconds, sizes are bytes.

Ky is a non-commercial research beta. A result describes musical resemblance; it is not a legal conclusion and not legal advice. Read the Results Disclaimer, and link it wherever you show a result.

Base URLhttps://ky.ax/api/v1
AuthBearer API key
curl https://ky.ax/api/v1/me \
  -H "Authorization: Bearer $KY_API_KEY"
import os, requests

r = requests.get(
    "https://ky.ax/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['KY_API_KEY']}"},
)
print(r.json())
const r = await fetch("https://ky.ax/api/v1/me", {
  headers: { Authorization: `Bearer ${process.env.KY_API_KEY}` },
});
console.log(await r.json());
{
  "user": { "id": "u_…", "email": "you@example.com" },
  "engine": { "online": true }
}

Authentication

Send an API key in the Authorization header of every request:

Authorization: Bearer ky_live_…

Keys

Create a key under Plugins & API keys in your account. A key is shown once, when you create it: store it somewhere safe. Ky keeps only a hash of it. Revoke a key there at any time and it stops working at once. Another account's check always answers 404.

What a key may do

A key has one of two scopes, chosen when it is made:

  • Sends and reads its own checks (the default, and what a connected plugin gets): create checks, send files, and read the checks it made, with their results and evidence records.
  • Full access (tick the box when you create the key, for your own code only): everything above for every check in the account, plus listing, downloading checked MIDI files, deleting, usage and billing.

A call outside a key's scope answers 403 scope. Give a plugin a full-access key and anyone who reads its settings file can read your account.

Device-code connect

For an app or plugin that other people install, don't ask them to paste a key. Start a connection, open the verification_url in their browser, and poll. They sign in (or create a free account), approve, and your next poll returns a key for them, exactly once. A key made this way sends files and reads the checks it made, and nothing else of their account.

  1. POST /api/v1/device/start with your app's name.
  2. Show the user_code and open verification_url.
  3. POST /api/v1/device/poll every interval seconds until status is approved (or denied, expired).

A code lasts ten minutes. A network can start 30 connections an hour.

curl -X POST https://ky.ax/api/v1/device/start \
  -H "Content-Type: application/json" \
  -d '{"client": "My App"}'

# open verification_url, then poll every 3 seconds:
curl -X POST https://ky.ax/api/v1/device/poll \
  -H "Content-Type: application/json" \
  -d '{"device_code": "…"}'
import time, webbrowser, requests

API = "https://ky.ax/api/v1"
start = requests.post(f"{API}/device/start", json={"client": "My App"}).json()
print("Your code:", start["user_code"])
webbrowser.open(start["verification_url"])

while True:
    time.sleep(start["interval"])
    poll = requests.post(f"{API}/device/poll", json={"device_code": start["device_code"]}).json()
    if poll["status"] != "pending":
        break
api_key = poll.get("api_key")  # only when status is "approved"
const API = "https://ky.ax/api/v1";
const post = (path, body) =>
  fetch(API + path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  }).then((r) => r.json());

const start = await post("/device/start", { client: "My App" });
console.log("Open", start.verification_url, "and enter", start.user_code);

let poll;
do {
  await new Promise((r) => setTimeout(r, start.interval * 1000));
  poll = await post("/device/poll", { device_code: start.device_code });
} while (poll.status === "pending");
const apiKey = poll.api_key; // only when status is "approved"
{
  "device_code": "N2c4…",
  "user_code": "KY-7VZJ-3ZDB",
  "verification_url": "https://ky.ax/connect?code=KY-7VZJ-3ZDB",
  "interval": 3,
  "expires_in": 600
}

{ "status": "approved", "api_key": "ky_live_…", "client": "My App" }

Quick start

Check a song end to end in three calls.

  1. Declare the file. POST /api/v1/check with JSON naming the file and its size. You get the check's id back, and an upload_url for the file.
  2. Send it. PUT the file's bytes to the upload_url, with its exact size as Content-Length. A MIDI file can go in one multipart request instead (see Uploading).
  3. Wait, then read the verdict. GET /api/v1/checks/{id} every few seconds until status is done or error. Each item has a verdict, a headline, an explanation and a url to its result page; for every measure the engine took, download evidence_url.

Set KY_API_KEY in your environment first. The Python sample uses requests; the JavaScript runs in Node 18 or later.

# 1. declare the song
SIZE=$(wc -c < my-song.mp3 | tr -d ' ')
curl https://ky.ax/api/v1/check \
  -H "Authorization: Bearer $KY_API_KEY" \
  -H "X-Ky-Client: My App" \
  -H "Content-Type: application/json" \
  -d "{\"mode\": \"catalogue\", \"files\": [{\"name\": \"my-song.mp3\", \"size\": $SIZE}]}"

# 2. send it to the upload_url you got back
curl --upload-file my-song.mp3 \
  "https://ky.ax/api/v1/checks/c_…/items/i_…/upload" \
  -H "Authorization: Bearer $KY_API_KEY" \
  -H "Content-Type: application/octet-stream"

# 3. poll until status is done, then read each item's verdict
curl "https://ky.ax/api/v1/checks/c_…?summary=0" \
  -H "Authorization: Bearer $KY_API_KEY"
import os, time, requests

API = "https://ky.ax/api/v1"
H = {"Authorization": f"Bearer {os.environ['KY_API_KEY']}", "X-Ky-Client": "My App"}
path = "my-song.mp3"
size = os.path.getsize(path)

# 1. declare the song
r = requests.post(f"{API}/check", headers=H, json={
    "mode": "catalogue",
    "files": [{"name": path, "size": size}],
})
r.raise_for_status()
check = r.json()

# 2. send it, streamed from disk with its exact length
with open(path, "rb") as f:
    requests.put("https://ky.ax" + check["items"][0]["upload_url"], data=f, headers={
        **H, "Content-Type": "application/octet-stream", "Content-Length": str(size),
    }).raise_for_status()

# 3. poll until done, then read the verdict
while True:
    check = requests.get(f"{API}/checks/{check['id']}", headers=H,
                         params={"summary": "0"}).json()
    if check["status"] in ("done", "error"):
        break
    time.sleep(5)
for item in check["items"]:
    print(item["filename"], item["verdict"], item["headline"])
    if item["evidence_url"]:
        evidence = requests.get(item["evidence_url"], headers=H).json()
import { readFile } from "node:fs/promises";

const API = "https://ky.ax/api/v1";
const headers = { Authorization: `Bearer ${process.env.KY_API_KEY}`, "X-Ky-Client": "My App" };
const call = async (url, init = {}) => {
  const r = await fetch(url, { ...init, headers: { ...headers, ...init.headers } });
  const body = await r.json();
  if (!r.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
  return body;
};

// 1. declare the song
const bytes = await readFile("my-song.mp3");
let check = await call(`${API}/check`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ mode: "catalogue", files: [{ name: "my-song.mp3", size: bytes.length }] }),
});

// 2. send it
await call("https://ky.ax" + check.items[0].upload_url, {
  method: "PUT",
  headers: { "Content-Type": "application/octet-stream" },
  body: bytes,
});

// 3. poll until done, then read the verdict
for (;;) {
  check = await call(`${API}/checks/${check.id}?summary=0`);
  if (check.status === "done" || check.status === "error") break;
  await new Promise((r) => setTimeout(r, 5000));
}
for (const item of check.items) {
  console.log(item.filename, item.verdict, item.headline);
  if (item.evidence_url) await call(item.evidence_url);
}
{
  "id": "c_8Hq2x0VnY1aK3mPq7RtZ9w",
  "mode": "catalogue",
  "items": [
    { "id": "i_Zp4mWq0eT7yLk2Nn5bVc8g", "kind": "file", "filename": "my-song.mp3",
      "size": 4812394, "media": "audio", "side": "a", "status": "uploading", "verdict": null,
      "upload_url": "/api/v1/checks/c_8Hq2x0VnY1aK3mPq7RtZ9w/items/i_Zp4mWq0eT7yLk2Nn5bVc8g/upload" }
  ],
  "url": "https://ky.ax/r/c_8Hq2x0VnY1aK3mPq7RtZ9w",
  "poll": "https://ky.ax/api/v1/checks/c_8Hq2x0VnY1aK3mPq7RtZ9w"
}

Uploading

There are two ways in. Songs and stems, in any common format, go in two steps, so a large file is streamed rather than held; MIDI can go in one request.

Songs and stems in two steps

  1. Declare the files: POST /api/v1/check with JSON {"mode", "files": [{"name", "size", "type"}]}. Each file item comes back with an upload_url.
  2. Send each file: PUT its raw bytes to the upload_url (on the host you called), with exactly the declared size as Content-Length.

The name's extension (or the optional type) says what a file is, and its first bytes must agree, or the upload is refused with 415. MIDI files may use the two steps too. A declared file that isn't sent within an hour is dropped. Ky transcribes a recording's sung melody, bass line, an instrumental line, chords, drums and words and compares them, so a Clear on a single stem covers only what Ky could hear in that stem.

MIDI in one request

Send multipart/form-data to POST /api/v1/check, one part per file (any field name), and an optional mode field. Or send a single file as the body with Content-Type: audio/midi and its name in X-Ky-Filename. A recording sent this way is refused with 415 recording_two_step.

Formats and limits

Songs and stemsMIDI
FormatsMP3, WAV, FLAC, M4A, AIFF, Ogg, Opus, WMA, WebM.mid, .midi
Largest file100 MB (the platform's request ceiling)100 MB
Longest recording10 minutes (see below)—
A checkup to 1,000 files (a technical ceiling, not an allowance)up to 1,000 files
A requestone file per PUT50 MB and 100 files in one multipart request (use the two steps for more)
A dayno daily limit at presentno daily limit at present
Keptdeleted once Ky has finished with it (its check and any comparison), within 24 hours in any casewith your check until you delete it

A recording longer than 10 minutes is accepted but not transcribed: checked against the catalogue, its item ends in error saying so; in a comparison, the other files are compared without it and the comparison's result says why. The other current values are always at GET /api/status (0 means no limit beyond the ceilings above).

# declare them together, then send each one
curl https://ky.ax/api/v1/check \
  -H "Authorization: Bearer $KY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "catalogue", "files": [
        {"name": "verse-take-2.wav", "size": 31457280},
        {"name": "vocal-stem.flac", "size": 9830400}]}'

# each item in the reply carries its own upload_url
curl --upload-file verse-take-2.wav \
  "https://ky.ax/api/v1/checks/c_…/items/i_…/upload" \
  -H "Authorization: Bearer $KY_API_KEY" \
  -H "Content-Type: application/octet-stream"
paths = ["verse-take-2.wav", "vocal-stem.flac"]
r = requests.post(f"{API}/check", headers=H, json={
    "mode": "catalogue",
    "files": [{"name": p, "size": os.path.getsize(p)} for p in paths],
})
r.raise_for_status()
for path, item in zip(paths, r.json()["items"]):
    with open(path, "rb") as f:
        requests.put("https://ky.ax" + item["upload_url"], data=f, headers={
            **H, "Content-Type": "application/octet-stream",
            "Content-Length": str(os.path.getsize(path)),
        }).raise_for_status()
import { readFile, stat } from "node:fs/promises";

const paths = ["verse-take-2.wav", "vocal-stem.flac"];
const files = await Promise.all(paths.map(async (name) => ({ name, size: (await stat(name)).size })));
const check = await call(`${API}/check`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ mode: "catalogue", files }),
});
for (const [i, item] of check.items.entries()) {
  await call("https://ky.ax" + item.upload_url, {
    method: "PUT",
    headers: { "Content-Type": "application/octet-stream" },
    body: await readFile(paths[i]),
  });
}
curl https://ky.ax/api/v1/check \
  -H "Authorization: Bearer $KY_API_KEY" \
  -F "file=@verse.mid" \
  -F "file=@chorus.mid"

# or one file as the body
curl https://ky.ax/api/v1/check \
  -H "Authorization: Bearer $KY_API_KEY" \
  -H "Content-Type: audio/midi" \
  -H "X-Ky-Filename: my-song.mid" \
  --data-binary @my-song.mid
files = [("file", (name, open(name, "rb"), "audio/midi"))
         for name in ["verse.mid", "chorus.mid"]]
r = requests.post("https://ky.ax/api/v1/check", headers=H, files=files)
const form = new FormData();
for (const name of ["verse.mid", "chorus.mid"]) {
  form.append("file", new Blob([await readFile(name)], { type: "audio/midi" }), name);
}
await fetch("https://ky.ax/api/v1/check", { method: "POST", headers, body: form });

Checks and results

GET /api/v1/checks/{id} returns the check and every item. A check and each item move through these statuses:

  • uploading
  • queued
  • running
  • done
  • error

Poll every 2 to 3 seconds until the check is done or error. A MIDI file is usually checked within a minute; a recording usually takes one to three, as its notes are written down first. engine.ahead_midi and engine.ahead_audio say how many files are waiting ahead of yours. A file identical to one you checked recently, with the same engine release, comes back done at once.

The four verdicts

Show the words, never the codes, and never words like “safe”, “pass”, “fail” or “infringing”.

  • Clearclear

    Nothing of note found in what Ky has.

  • Worth a looklook

    Something resembles another work: a short or common passage, or an element such as the chords or rhythm. Have a listen.

  • Likely shared materialshared

    A long, uncommon passage lines up closely, or the melody, the words or several elements are closely alike.

  • Looks like a versionversion

    Most of the piece lines up with one work, or its melody and more are closely alike, as a cover or arrangement would be. If it's yours, that's expected.

Results describe musical resemblance: melody and bass line, chords, rhythm, form and words. They are not a legal conclusion and not legal advice. Link the Results Disclaimer wherever you show one.

An item

verdictOne of the four codes, or null until done.
headlineOne plain sentence about the result.
explanationA short account of the numbers in plain English.
findingsUp to 10 readings that count, the headline's first (primary): the work, how the passages relate, the voice, how much of yours lines up and where, and how common the material is. A held or repeated note never counts.
workflowA brief of the detailed comparison: which techniques ran and what they found.
urlThe item on its result page, to offer people.
evidence_urlOnce done, the full evidence record: every measure for every ranked match.
summaryEverything the result page shows, as the engine wrote it, piano-roll exhibits included. Leave it out with ?summary=0.
errorWhy an item couldn't be checked, fit to show.

The evidence record and your MIDI file are also at …/items/{item}/result and …/items/{item}/file. A recording is deleted once Ky has finished with it, so there is no file to download for one. The whole check as a PDF — the results, the passages that lined up, the numbers and the piano rolls, with a disclaimer line on every page and the Results Disclaimer in full at the end — is at …/checks/{id}/report.pdf.

curl https://ky.ax/api/v1/checks/c_8Hq2x0VnY1aK3mPq7RtZ9w \
  -H "Authorization: Bearer $KY_API_KEY"
check = requests.get(f"{API}/checks/{check_id}", headers=H).json()
for item in check["items"]:
    if item["status"] == "done":
        print(item["verdict"], item["headline"])
        for f in item["findings"]:
            print(" ", f["work"]["title"], f["relationship"], f["proportion_of_yours"])
const check = await call(`${API}/checks/${id}`);
for (const item of check.items.filter((i) => i.status === "done")) {
  console.log(item.verdict, item.headline);
  for (const f of item.findings) console.log(" ", f.work.title, f.relationship, f.proportion_of_yours);
}
{
  "id": "c_8Hq2x0VnY1aK3mPq7RtZ9w",
  "status": "done",
  "mode": "catalogue",
  "source": "plugin:My App",
  "created_at": 1793098914,
  "updated_at": 1793098921,
  "url": "https://ky.ax/r/c_8Hq2x0VnY1aK3mPq7RtZ9w",
  "engine": { "online": true, "ahead": 0, "ahead_midi": 0, "ahead_audio": 0, "audio_online": true },
  "items": [
    {
      "id": "i_Zp4mWq0eT7yLk2Nn5bVc8g",
      "kind": "file",
      "media": "midi",
      "filename": "my-song.mid",
      "status": "done",
      "verdict": "look",
      "headline": "Worth a look: bars 3–4 of your melody resemble “Example Tune” (Example Writer, 1971); that material is uncommon in the catalogue.",
      "explanation": "Eight notes in bars 3–4 of your melody line up with bars 9–10 of “Example Tune”. Material like this is uncommon in the catalogue.",
      "findings": [
        {
          "primary": true,
          "work": { "title": "Example Tune", "artist": "Example Writer", "year": 1971 },
          "relationship": "shared passage",
          "voice": "melody",
          "aligned_notes": 8,
          "proportion_of_yours": 0.12,
          "bars_yours": [3, 4],
          "bars_theirs": [9, 10],
          "commonness": "uncommon",
          "has_exhibit": true
        }
      ],
      "url": "https://ky.ax/r/c_8Hq2x0VnY1aK3mPq7RtZ9w#i_Zp4mWq0eT7yLk2Nn5bVc8g",
      "evidence_url": "https://ky.ax/api/v1/checks/c_8Hq2x0VnY1aK3mPq7RtZ9w/items/i_Zp4mWq0eT7yLk2Nn5bVc8g/result",
      "summary": { "…": "…" }
    }
  ]
}

Modes

A check's mode says what each file is compared with. Give it as the mode field of a declaration or a multipart request, as ?mode=, or in X-Ky-Mode.

catalogueEach file against the catalogue. The default.
pairwiseYour files against each other (two or more songs, stems or MIDI files; a recording is transcribed first and compared on its transcription and, between recordings, on its sound). One extra item of kind: "pairwise" carries the result; its findings are pairs of your files.
bothThe two together: each file against the catalogue, and the files against each other.

Comparing your songs with a second set is on ky.ax only for now; through the API it answers 400 sets_web_only.

curl https://ky.ax/api/v1/check \
  -H "Authorization: Bearer $KY_API_KEY" \
  -F "mode=pairwise" \
  -F "file=@take-1.mid" \
  -F "file=@take-2.mid" \
  -F "file=@take-3.mid"
names = ["take-1.mid", "take-2.mid", "take-3.mid"]
r = requests.post(f"{API}/check", headers=H, data={"mode": "pairwise"},
                  files=[("file", (n, open(n, "rb"), "audio/midi")) for n in names])
const form = new FormData();
form.append("mode", "pairwise");
for (const n of ["take-1.mid", "take-2.mid", "take-3.mid"]) {
  form.append("file", new Blob([await readFile(n)], { type: "audio/midi" }), n);
}
await call(`${API}/check`, { method: "POST", body: form });

Endpoints

Every endpoint takes your key, except where it says public; those marked full access need a key made with it. The OpenAPI document has each one's parameters, schemas and examples.

  • POST/api/v1/device/startStart a device-code connection public
  • POST/api/v1/device/pollPoll it for a key public
  • GET/api/v1/meWho the key belongs to
  • POST/api/v1/checkCreate a check (multipart, a MIDI body, or a declaration)
  • PUT/api/v1/checks/{id}/items/{item}/uploadSend a declared file
  • GET/api/v1/checksList your checks full access
  • GET/api/v1/checks/{id}A check and its results
  • DELETE/api/v1/checks/{id}Delete a check full access
  • GET/api/v1/checks/{id}/items/{item}/resultAn item's evidence record
  • GET/api/v1/checks/{id}/report.pdfThe similarity report (PDF)
  • GET/api/v1/checks/{id}/items/{item}/fileA checked MIDI file full access
  • GET/api/v1/usageUsage this period full access
  • GET/api/v1/billingYour plan full access
  • GET/api/statusEngine, queue and current limits public
  • GET/api/v1/openapi.jsonThe OpenAPI 3.1 document public

Listing and deleting

GET /api/v1/checks lists your checks, newest first, with how many files got each verdict. It takes limit (1 to 100, default 50). When there are more, pass next_cursor back as cursor; it is null on the last page.

DELETE /api/v1/checks/{id} removes a check, its stored files and its evidence records for good. Files it used still count towards the period's usage.

curl "https://ky.ax/api/v1/checks?limit=20" \
  -H "Authorization: Bearer $KY_API_KEY"

curl -X DELETE https://ky.ax/api/v1/checks/c_8Hq2x0VnY1aK3mPq7RtZ9w \
  -H "Authorization: Bearer $KY_API_KEY"
checks, cursor = [], None
while True:
    page = requests.get(f"{API}/checks", headers=H,
                        params={"limit": 100, **({"cursor": cursor} if cursor else {})}).json()
    checks += page["checks"]
    cursor = page["next_cursor"]
    if not cursor:
        break

requests.delete(f"{API}/checks/{checks[-1]['id']}", headers=H).raise_for_status()
const checks = [];
let cursor = null;
do {
  const q = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) });
  const page = await call(`${API}/checks?${q}`);
  checks.push(...page.checks);
  cursor = page.next_cursor;
} while (cursor);

await call(`${API}/checks/${checks.at(-1).id}`, { method: "DELETE" });
{
  "checks": [
    {
      "id": "c_8Hq2x0VnY1aK3mPq7RtZ9w",
      "status": "done",
      "mode": "catalogue",
      "source": "plugin:My App",
      "created_at": 1793098914,
      "n_items": 1,
      "first_file": "my-song.mid",
      "verdicts": { "clear": 0, "look": 1, "shared": 0, "version": 0 },
      "pair_verdict": null,
      "url": "https://ky.ax/r/c_8Hq2x0VnY1aK3mPq7RtZ9w"
    }
  ],
  "next_cursor": null
}

Usage and billing

GET /api/v1/usage gives what your account has used this period: files against the plan's allowance (every file counts: a song, a stem or a MIDI file), how many were recordings and how many MIDI, checks by kind, and files per day. It is the same data as the Usage page in your account.

GET /api/v1/billing gives your plan and its state: which plan, monthly or yearly, when it renews or ends, and the extra files left. Your card and invoices stay on the Billing page, never in the API. Both need a key with full access.

Ky is free while it's in beta, and paid plans aren't open yet. So today both read "billing": "off" and "plan": "free", and nothing is enforced ("enforced": false); files are still counted, so you can see them. The planned plans are on the Pricing page.

curl https://ky.ax/api/v1/usage \
  -H "Authorization: Bearer $KY_API_KEY"

curl https://ky.ax/api/v1/billing \
  -H "Authorization: Bearer $KY_API_KEY"
usage = requests.get(f"{API}/usage", headers=H).json()
f = usage["files"]
print(f"{f['used']} files used, {f['left']} left this period")
const usage = await call(`${API}/usage`);
const { used, left } = usage.files;
console.log(`${used} files used, ${left} left this period`);
{
  "billing": "off",
  "enforced": false,
  "plan": "free",
  "period": { "start": 1790812800, "end": 1793491200, "kind": "calendar_month" },
  "files": { "used": 8, "allowance": 20, "left": 12, "extra_left": 0, "recordings": 3, "midi": 5 },
  "checks": { "total": 12, "catalogue": 11, "each_other": 1, "another_set": 0 },
  "daily": [{ "date": "2026-10-01", "files": 0 }, "…"],
  "resets_at": 1793491200
}

Errors

Anything but a 2xx carries the same JSON: a stable code for your program, and a message in plain English that you can show people as it is.

StatusCodesWhat to do
400no_files too_many need_two too_many_recordings sets_web_only empty size_mismatch incomplete bad_request bad_cursorFix the request; the message says how.
401unauthenticatedSend a valid key. A revoked key is refused.
403terms scopeAccept the Terms on ky.ax once, signed in; or the key lacks full access for this call.
404not_foundNo such check or item, or it isn't yours.
409uploaded busyThat file is already sent, or being sent.
410goneThe upload expired: declare the file again.
411length_requiredSend a Content-Length.
413too_large too_large_totalSend smaller files or fewer at a time.
415not_midi not_audio recording_two_step unsupported audio_offCheck the format; send recordings in two steps.
429quota audio_quota throttledAn allowance is used up. Daily ones reset at midnight UTC.
503audio_busyToo many recordings are waiting (not applied at present). Try again in a few minutes; MIDI still works.

When paid plans open, 403 set_too_large and 429 files_used can also come back.

{
  "error": {
    "code": "recording_two_step",
    "message": "demo.mp3 is a recording. Recordings are sent in two steps: declare them, then send each file (see https://ky.ax/plugins)."
  }
}

Rate limits

Ky's edge allows 3,000 requests a minute to /api/* from one address, then refuses for a minute with 429; that answer comes from the edge, so it may not carry the JSON error shape. Poll a check every few seconds and you will never meet it. What else applies:

  • No daily allowances at present (when there are, a refusal is 429 audio_quota or 429 quota, reset at midnight UTC).
  • No site-wide ceiling on waiting recordings at present (when there is one, new recordings may be refused for a while with 503 audio_busy). A recording not checked within a day is deleted and its item ends in error.
  • 30 device-code connections an hour from one network (429 throttled).
  • The per-file and per-check limits under Uploading.

Be kind to the engines: poll a check every 2 to 3 seconds, not faster, and stop when it's done. GET /api/status is public and gives the current limits; a limit of 0 means none applies.

curl https://ky.ax/api/status
status = requests.get("https://ky.ax/api/status").json()
print(status["audio"]["max_bytes"], status["audio"]["daily_user"])
const status = await (await fetch("https://ky.ax/api/status")).json();
console.log(status.audio.max_bytes, status.audio.daily_user);
{
  "beta": true,
  "engine": { "online": true, "queue": 2, "queue_midi": 2, "queue_audio": 0, "…": "…" },
  "limits": { "files_per_check": 0, "file_bytes": 104857600, "daily_anon": 0, "daily_user": 0 },
  "audio": { "enabled": true, "max_bytes": 104857600, "per_check": 1000,
             "daily_anon": 0, "daily_user": 0, "engine_online": true },
  "sets": { "enabled": true, "per_set": 1000 }
}

Webhooks

Not yet. For now, poll GET /api/v1/checks/{id} until it's done. If you need Ky to call you when a check finishes, tell us at hello@ky.ax.

Changelog

  1. Two new elements can appear in a result's families: sample (recorded sound: the same recorded sound in two recordings) and version (music as recorded, compared with a model trained on versions of songs), with their techniques "Recorded sound comparison" and "Comparison of the music as recorded".

  2. A recording can be up to 10 minutes long (it was an hour). A longer one is accepted but not transcribed: see Formats and limits. A comparison with no pair left to compare ends in error saying why, instead of a verdict.

  3. This page and the OpenAPI document. Keys now have a scope: plugins and existing keys send files and read their own checks; a key with full access, made under Plugins & API keys, can list, download, delete, and read usage and billing. New: list checks with a cursor, delete a check, download an item's evidence record and MIDI file, usage and billing. Each item now carries its explanation, findings in short, its url and evidence_url; ?summary=0 leaves the full summary out.

  4. Recordings, through the two-step upload.

  5. Version 1: device-code connect, POST /api/v1/check for MIDI, and results.

Version 1 changes only by adding: new fields and endpoints may appear, and existing ones keep their meaning.