The Valiha API
Cette documentation est rédigée en anglais.
Sur cette page
The API lets your own app do what the studio does. Your server sends a recording and its lyrics. Valiha answers at once, analyses the song in a few minutes, then gives you the key, the tempo, the sections, the chords on every beat, a ChordPro chart, the separated parts (stems) and the sung notes.
Base URL:
https://valiha.marindrano.com/v1Requests and answers are JSON, except the recording upload (multipart), the ChordPro text and the stems (audio). All times are in seconds. Dates are ISO 8601 in UTC.
Keys and plans
The API is part of the Plus plan and of partner plans. A key of an organization on the Free plan is refused with a 401 and the message The API is available on Plus and Partner plans.
To create a key:
- Sign in to the studio and switch to the organization the songs belong to (in the account menu).
- Open API in the sidebar, under Organization. Only owners and admins see it.
- Click Create key, give it a name after the app that will use it (for example
StarApp production), then Create the key. - Copy the API key and the Webhook secret and keep them on your server. Valiha keeps neither and shows them only this once. Click Done, I copied them.
A key looks like vk_live_ followed by 42 letters and digits. Create one key per app, so you can revoke one without stopping the others. Revoke stops a key at once; it stays in the list as revoked.
The API page also shows the active keys, the songs created through the API this month and the time of the last call. Songs created with a key belong to the key's organization: they appear in the studio's project list with a "via API" mark, and their song page shows the last webhook delivery.
Never put a key in a mobile app or a web page. Call the API from your server.
Authentication
Send the key in the Authorization header of every request:
Authorization: Bearer vk_live_...Every request runs in the organization that owns the key. A song of another organization answers 404, as if it did not exist.
| Problem | Status | Message |
|---|---|---|
| No header, or not a Valiha key | 401 | Send your API key as Authorization: Bearer vk_live_... |
| Unknown key | 401 | This API key is not valid |
| Revoked key | 401 | This API key was revoked |
| Organization on the Free plan | 401 | The API is available on Plus and Partner plans |
A 401 also carries WWW-Authenticate: Bearer realm="valiha".
Endpoints
| Method | Path | What it does | Answers |
|---|---|---|---|
| POST | /v1/songs | Create a song and start its analysis | 202, JSON |
| GET | /v1/songs/{id} | One song | 200, the song object |
| GET | /v1/songs?externalId=... | Your songs with this externalId, newest first | 200, a list of song objects |
| GET | /v1/songs/{id}/chordpro | The chart as ChordPro text | 200, text/plain |
| GET | /v1/songs/{id}/stems/{name} | One part of the current analysis | 200 or 206, audio/mp4 |
| POST | /v1/songs/{id}/analyze | Analyze the song again, with new options if you send them | 202, JSON |
| DELETE | /v1/songs/{id} | Delete the song, its recording and its stems | 204 |
Create a song
POST /v1/songs takes the song and its recording in one request. Send the recording in one of two ways:
- JSON with
audioUrl: Valiha downloads the file from your link. - A multipart form with the file in a field named
audio.
Fields
| Field | Required | Rules |
|---|---|---|
title | yes | 1 to 200 characters |
artist | no | up to 200 characters |
language | yes | the language of the lyrics: mg (Malagasy), fr or en |
lyrics | yes | up to 20,000 characters; may be "" in JSON |
audioUrl | JSON only | an https:// link to the recording |
audio | multipart only | the recording, as a file field |
webhookUrl | no | an https:// address Valiha posts to when the analysis ends (see Webhooks) |
externalId | no | your own id for the song, up to 120 characters, to find it again |
options | no | the parts and the notes to make (see Parts and notes) |
The lyrics are plain text, one sung line per line. A line such as [Verse 1] or [Chorus] names the section that follows, as in the studio (see Using the studio).
With a link to the recording
curl -X POST https://valiha.marindrano.com/v1/songs \
-H "Authorization: Bearer $VALIHA_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Antsoantso",
"artist": "Rija",
"language": "mg",
"lyrics": "[Verse 1]\nTompo ô, Ianao no hirako\nNy fitiavanao no tanjako",
"audioUrl": "https://example.com/antsoantso.mp3",
"webhookUrl": "https://example.com/hooks/valiha",
"externalId": "song-1042",
"options": { "stems": 6, "melody": ["game", "choir"] }
}'Valiha fetches audioUrl from its own server before it answers. The link must:
- start with
https://, and stay on https if it redirects; - answer with a 2xx status within 60 seconds;
- send an
audio/*Content-Type (audio/mpeg,audio/mp4,audio/wavand so on;application/octet-streamis refused); - be at most 80 MB.
A signed link to your storage (S3, R2, Google Cloud Storage) works well, as long as the object has an audio Content-Type.
With the file
curl -X POST https://valiha.marindrano.com/v1/songs \
-H "Authorization: Bearer $VALIHA_KEY" \
-F "title=Antsoantso" \
-F "language=mg" \
-F "lyrics=<lyrics.txt" \
-F "audio=@antsoantso.mp3" \
-F "webhookUrl=https://example.com/hooks/valiha" \
-F "externalId=song-1042" \
-F 'options={"stems": 6, "melody": ["game", "choir"]}'- The file goes in the field
audio, at most 80 MB. - Its type is the one the form gives it. When that is not an audio type (curl sends an
.mp3asapplication/octet-stream), Valiha takes it from the file name:.mp3,.m4a,.mp4,.aac,.wav,.flac,.ogg,.oga,.opus,.aif,.aiff,.webm. optionsis one text field that holds the same JSON as in a JSON request.- An empty field counts as missing. Send at least one character of lyrics in a form.
audioUrlis not read from a form: send the file.
Parts and notes
options chooses what the analysis makes, as the fourth step of a new project in the studio does. Leave it out for 4 parts and the main melody.
{ "stems": 4, "melody": ["game"] }stems is how many parts the recording is split into:
stems | Parts you get |
|---|---|
2 | vocals, music (everything but the voice) |
4 | vocals, drums, bass, other (the default) |
6 | vocals, drums, bass, guitar, piano, other |
melody lists the note models that listen to the vocals part, each at most once:
| Model | What it writes | Where you get it |
|---|---|---|
game | The main melody: the sung tune as notes on the beat grid | melody in the song object |
choir | Every voice it hears, several notes at once. Experimental | voices in the song object |
[] writes no notes. Both fields are required inside options. Any other field in options is ignored.
What to expect from the engine:
- With
6, the first song on an engine downloads the six part model (about 55 MB), so it takes longer. - When the engine does not have GAME, it writes the melody with its older method:
melody.modelis thenclassic. - When the engine does not have the Choir model, or a note model fails, the song is still ready:
voices(ormelody) isnull.
The chords, the beats and the words come out the same with every choice.
The answer
The song is created and its analysis queued before Valiha answers:
HTTP/1.1 202 Accepted
{
"id": "cm3k2x8f40001",
"externalId": "song-1042",
"status": "queued",
"options": { "stems": 6, "melody": ["game", "choir"] },
"createdAt": "2026-10-27T09:12:00.000Z"
}Keep the id. A request that fails (a bad field, an audio link that does not answer) creates nothing.
Get the result
An analysis takes a few minutes. Ask for the song now and then, or give a webhookUrl and wait for Valiha's call. You can do both.
Ask for it
curl https://valiha.marindrano.com/v1/songs/cm3k2x8f40001 \
-H "Authorization: Bearer $VALIHA_KEY"Ask every 10 seconds or so until status is ready or failed:
status | Meaning |
|---|---|
queued | Waiting for an engine |
analyzing | An engine is working on it |
ready | Done: chart, stems, melody and voices are filled |
failed | It did not work: error says why |
When no engine is online, the song stays queued and Valiha keeps trying for about eight minutes, then the song fails with No engine is online.
Find songs by your id
curl "https://valiha.marindrano.com/v1/songs?externalId=song-1042" \
-H "Authorization: Bearer $VALIHA_KEY"The answer is a list of song objects with that externalId, newest first, at most 50. Without ?externalId= the answer is a 400.
The song object
Every endpoint and every webhook gives songs in this shape:
{
"id": "cm3k2x8f40001",
"externalId": "song-1042",
"status": "ready",
"title": "Antsoantso",
"artist": "Rija",
"language": "mg",
"key": "G",
"bpm": 91,
"timeSignature": "4/4",
"durationSeconds": 212.4,
"error": null,
"chart": {
"chordpro": "{title: Antsoantso}\n{key: G}\n{tempo: 91}\n{time: 4/4}\n...",
"sections": [
{ "kind": "verse", "label": "Verse 1", "start": 8.2, "end": 41.6 }
],
"chords": [
{ "time": 8.21, "beat": 12, "name": "G" },
{ "time": 10.84, "beat": 16, "name": "D/F#" }
]
},
"options": { "stems": 6, "melody": ["game", "choir"] },
"stems": ["vocals", "drums", "bass", "guitar", "piano", "other"],
"melody": {
"version": 1,
"model": "game",
"engine": { "version": "0.1.0" },
"sha1": "9b1c4e...",
"tuning": -12.5,
"notes": [
{ "start": 8.31, "end": 8.74, "midi": 67, "beat": 12, "length": 1, "conf": 1, "syllable": [0, 0, 0, 0] }
],
"timings": { "notes": 0.41 }
},
"voices": {
"version": 1,
"model": "choir",
"sha1": "9b1c4e...",
"tuning": -12.5,
"notes": [
{ "start": 8.3, "end": 8.76, "midi": 67, "velocity": 0.81 },
{ "start": 8.3, "end": 8.76, "midi": 59, "velocity": 0.44 }
],
"timings": { "features": 3.2, "model": 18.7 }
},
"createdAt": "2026-10-27T09:12:00.000Z",
"updatedAt": "2026-10-27T09:15:32.000Z"
}| Field | Type | Meaning |
|---|---|---|
id | string | Valiha's id of the song |
externalId | string or null | Your id, as you sent it |
status | string | queued, analyzing, ready or failed |
title, artist, language | string | As you sent them (artist may be null) |
key | string or null | The key, such as G or Em |
bpm | number or null | The tempo in beats per minute |
timeSignature | string or null | Such as 4/4 or 3/4 |
durationSeconds | number or null | The length of the recording |
error | string or null | Why the analysis failed, only when status is failed |
chart | object or null | The chart, null until the first analysis is ready |
chart.chordpro | string | The chart as ChordPro text, the same as the /chordpro endpoint |
chart.sections | list | Each section: kind (verse, chorus, bridge, intro, outro, instrumental, other), label ("" when none), start and end in seconds |
chart.chords | list | Each chord change: time in seconds, beat (its index in the song's beats) and name |
options | object | What the song's analysis is asked to make: the options you sent last, else {"stems": 4, "melody": ["game"]} |
stems | list | The parts of the current analysis you can download, [] when there are none |
melody | object or null | The main melody (melody.json), null when the analysis has none |
voices | object or null | Every voice (voices.json), null when the analysis has none |
createdAt, updatedAt | string | ISO 8601 dates |
Good to know:
chart.sectionsandchart.chordsare read from the engine's analysis with the default chart settings: letter names, a chord at each change, slash chords.chart.chordprois the chart as it is now, so it also holds the fixes and the transposition someone made in the studio.- During a new analysis (status
queuedoranalyzingagain),chart,stems,melodyandvoicesstill show the previous result until the new one is ready.optionsalready shows the new choice. - The full description of
melody,voices, the stems and the ChordPro text is in Data formats.
The ChordPro text
curl https://valiha.marindrano.com/v1/songs/cm3k2x8f40001/chordpro \
-H "Authorization: Bearer $VALIHA_KEY" \
-o antsoantso.choThe answer is text/plain; charset=utf-8. Before the first analysis is ready it is a 404 with The chart is not ready yet. A chart edited in the studio can start with {x_valiha_chart: ...} and {x_valiha_fixes: ...} lines: they keep the studio's settings, and ChordPro readers skip them. See Studio directives.
The stems
curl https://valiha.marindrano.com/v1/songs/cm3k2x8f40001/stems/vocals \
-H "Authorization: Bearer $VALIHA_KEY" \
-o vocals.m4a{name} is one of the names in the song's stems list: vocals, drums, bass, guitar, piano, other or music. Each part is AAC audio in an .m4a file, sent as audio/mp4.
Rangerequests work (206 Partial Content), so a player can stream a part.- Each answer has an
ETag; send it back inIf-None-Matchto get a 304 when nothing changed. X-Valiha-Versionnames the analysis run the stems come from. It changes when a new analysis brings new stems.- A name that is not a part, or a part the current analysis does not have, is a 404.
Analyze again
POST /v1/songs/{id}/analyze runs the analysis again on the same recording. An analysis still running is cancelled first.
Without a body, the song keeps its options. With a JSON body, new options become the song's:
curl -X POST https://valiha.marindrano.com/v1/songs/cm3k2x8f40001/analyze \
-H "Authorization: Bearer $VALIHA_KEY" \
-H "Content-Type: application/json" \
-d '{ "options": { "stems": 2, "melody": ["game"] } }'The answer has the same shape as song creation, with the options the new analysis runs with:
HTTP/1.1 202 Accepted
{
"id": "cm3k2x8f40001",
"externalId": "song-1042",
"status": "queued",
"options": { "stems": 2, "melody": ["game"] },
"createdAt": "2026-10-27T09:12:00.000Z"
}The song's webhook, if it has one, is called again when this analysis ends.
Delete a song
curl -X DELETE https://valiha.marindrano.com/v1/songs/cm3k2x8f40001 \
-H "Authorization: Bearer $VALIHA_KEY"The answer is a 204 with no body. The song, its recording and the stems of every analysis are deleted, and an analysis still running is stopped. This cannot be undone.
Webhooks
When a song has a webhookUrl, Valiha posts the song to it each time an analysis ends:
POST https://example.com/hooks/valiha
Content-Type: application/json
User-Agent: Valiha-Webhook/1
X-Valiha-Event: song.ready
X-Valiha-Timestamp: 1793092532
X-Valiha-Signature: sha256=5f0c9a...
{"event": "song.ready", "song": { ...the song object... }}| Event | When |
|---|---|
song.ready | The analysis is done. song.chart, song.stems, song.melody and song.voices are filled |
song.failed | The analysis failed, or no engine could take it. song.error says why |
Delivery
- Answer with any 2xx status within 10 seconds. Do the slow work after you answer.
- A redirect is not followed and counts as a failure, like a timeout or any other status.
- After a failure Valiha tries again 30 seconds later, then 5 minutes after that, then stops: three tries in all.
- The same event can reach you twice (for example when your answer arrived after the 10 seconds). Use the song's
idandupdatedAtto skip one you already handled. - Polling
GET /v1/songs/{id}always works, so a missed webhook is never a lost result.
Check the signature
Each key comes with a webhook secret, shown once next to the key. Every webhook of a song created with that key is signed with it:
- Take
X-Valiha-Timestamp(Unix seconds) and the raw body, exactly the bytes you received, before any JSON parsing. - Compute the HMAC SHA-256 of
<timestamp>.<raw body>, with the secret as the key. Use the secret as text, as you copied it: do not decode it from hex. - Write it as
sha256=followed by the lowercase hex digest, and compare it withX-Valiha-Signaturein constant time. - Refuse a timestamp more than five minutes away from your clock, so an old request cannot be replayed.
JavaScript (Node):
import { createHmac, timingSafeEqual } from "node:crypto";
export function isFromValiha(rawBody, headers, webhookSecret) {
const timestamp = headers["x-valiha-timestamp"];
const signature = headers["x-valiha-signature"] ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || !(age < 300)) return false;
const expected =
"sha256=" +
createHmac("sha256", webhookSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Python:
import hashlib
import hmac
import time
def is_from_valiha(raw_body: bytes, headers, webhook_secret: str) -> bool:
timestamp = headers.get("X-Valiha-Timestamp", "")
signature = headers.get("X-Valiha-Signature", "")
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age >= 300:
return False
digest = hmac.new(
webhook_secret.encode(),
timestamp.encode() + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, "sha256=" + digest)Errors
Errors come back as JSON, with a code for your program and a message for people:
HTTP/1.1 404 Not Found
{ "error": { "code": "not_found", "message": "This song does not exist" } }| Code | Status | Meaning |
|---|---|---|
invalid | 400 | A field is missing or wrong. The message names it, such as options.stems: Invalid input |
unauthorized | 401 | The key is missing, wrong or revoked, or the plan has no API |
not_found | 404 | No such song in your organization, no chart yet, or no such stem |
limit | 429 | Too many requests. Wait the seconds in Retry-After |
server | 500 | Something failed on Valiha's side. Try again a little later |
Messages you may meet:
| Message | What to do |
|---|---|
Send application/json with audioUrl, or multipart/form-data with an audio file | Set the Content-Type of the request |
audioUrl is required | A JSON request needs audioUrl |
Send the audio file in the field audio | A form needs a file field named audio |
The audio URL answered 403 | Valiha could not download your link. Check that it is public or signed |
The audio URL must serve an audio/* content type | Set the Content-Type of the stored file |
The audio file is larger than 80 MB | Send a smaller file (an mp3 at 192 kbps is far below) |
This song has no audio to analyse | The song has no recording; create it again |
The chart is not ready yet | Wait for status to be ready |
This song has no such stem yet | Check the song's stems list |
An audio storage outage answers a 503 with Retry-After: 10 on the stem downloads. Try again after the delay.
Limits
| What | Limit |
|---|---|
| Requests | 60 a minute per key. The count is kept per server, so treat 60 as a ceiling |
| Recording | 80 MB, by link or by file |
Downloading audioUrl | 60 seconds |
| Lyrics | 20,000 characters |
| Title, artist | 200 characters each |
externalId | 120 characters |
Songs listed by externalId | the 50 newest |
| Webhook answer | 10 seconds, three tries |
Complete examples
Each example creates a song from a file, waits for the analysis, and saves the ChordPro chart and every stem. Set VALIHA_KEY in your environment first.
curl
This script needs curl and jq.
#!/usr/bin/env bash
set -euo pipefail
API="https://valiha.marindrano.com/v1"
AUTH="Authorization: Bearer $VALIHA_KEY"
# 1. Create the song from a file on disk
id=$(curl -sS -X POST "$API/songs" -H "$AUTH" \
-F "title=Antsoantso" \
-F "language=mg" \
-F "lyrics=<lyrics.txt" \
-F "audio=@antsoantso.mp3" \
-F 'options={"stems": 4, "melody": ["game"]}' | jq -r .id)
echo "Created $id"
# 2. Wait for the analysis
while :; do
status=$(curl -sS "$API/songs/$id" -H "$AUTH" | jq -r .status)
echo "Status: $status"
case "$status" in ready | failed) break ;; esac
sleep 10
done
# 3. Save the song, the chart and the stems
curl -sS "$API/songs/$id" -H "$AUTH" -o song.json
if [ "$status" = failed ]; then
jq -r .error song.json
exit 1
fi
curl -sS "$API/songs/$id/chordpro" -H "$AUTH" -o antsoantso.cho
for stem in $(jq -r '.stems[]' song.json); do
curl -sS "$API/songs/$id/stems/$stem" -H "$AUTH" -o "$stem.m4a"
doneJavaScript
Node 20 or later, no packages: fetch, FormData and fs.openAsBlob are built in.
import { openAsBlob } from "node:fs";
import { writeFile } from "node:fs/promises";
const API = "https://valiha.marindrano.com/v1";
const headers = { Authorization: `Bearer ${process.env.VALIHA_KEY}` };
async function call(path, init = {}) {
const response = await fetch(`${API}${path}`, {
...init,
headers: { ...headers, ...init.headers },
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error?.message ?? `HTTP ${response.status}`);
}
return response;
}
// 1. Create the song from a file on disk
const form = new FormData();
form.set("title", "Antsoantso");
form.set("language", "mg");
form.set("lyrics", "[Verse 1]\nTompo ô, Ianao no hirako\nNy fitiavanao no tanjako");
form.set("externalId", "song-1042");
form.set("options", JSON.stringify({ stems: 4, melody: ["game"] }));
form.set(
"audio",
await openAsBlob("antsoantso.mp3", { type: "audio/mpeg" }),
"antsoantso.mp3",
);
const created = await (await call("/songs", { method: "POST", body: form })).json();
console.log("Created", created.id);
// 2. Wait for the analysis
let song;
for (;;) {
song = await (await call(`/songs/${created.id}`)).json();
if (song.status === "ready" || song.status === "failed") break;
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
if (song.status === "failed") throw new Error(song.error);
// 3. Save the chart and the stems
console.log(song.key, song.bpm, song.chart.chords.length, "chord changes");
await writeFile("antsoantso.cho", song.chart.chordpro);
for (const name of song.stems) {
const audio = await call(`/songs/${song.id}/stems/${name}`);
await writeFile(`${name}.m4a`, Buffer.from(await audio.arrayBuffer()));
}With a link instead of a file, send JSON:
await call("/songs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Antsoantso",
language: "mg",
lyrics: "",
audioUrl: "https://example.com/antsoantso.mp3",
webhookUrl: "https://example.com/hooks/valiha",
options: { stems: 2, melody: [] },
}),
});A webhook receiver with node:http, using isFromValiha from Check the signature:
import { createServer } from "node:http";
createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString("utf8");
if (!isFromValiha(rawBody, request.headers, process.env.VALIHA_WEBHOOK_SECRET)) {
response.writeHead(401).end();
return;
}
response.writeHead(204).end();
const { event, song } = JSON.parse(rawBody);
console.log(event, song.id, song.status);
}).listen(3000);Python
With requests (pip install requests). httpx takes the same arguments.
import json
import os
import time
import requests
API = "https://valiha.marindrano.com/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['VALIHA_KEY']}"
def check(response: requests.Response) -> requests.Response:
if not response.ok:
try:
message = response.json()["error"]["message"]
except ValueError:
message = f"HTTP {response.status_code}"
raise RuntimeError(message)
return response
# 1. Create the song from a file on disk
with open("lyrics.txt", encoding="utf-8") as f:
lyrics = f.read()
with open("antsoantso.mp3", "rb") as audio:
created = check(
session.post(
f"{API}/songs",
data={
"title": "Antsoantso",
"language": "mg",
"lyrics": lyrics,
"externalId": "song-1042",
"options": json.dumps({"stems": 4, "melody": ["game"]}),
},
files={"audio": ("antsoantso.mp3", audio, "audio/mpeg")},
timeout=300,
)
).json()
print("Created", created["id"])
# 2. Wait for the analysis
while True:
song = check(session.get(f"{API}/songs/{created['id']}", timeout=30)).json()
if song["status"] in ("ready", "failed"):
break
time.sleep(10)
if song["status"] == "failed":
raise RuntimeError(song["error"])
# 3. Save the chart and the stems
print(song["key"], song["bpm"], len(song["chart"]["chords"]), "chord changes")
with open("antsoantso.cho", "w", encoding="utf-8") as f:
f.write(song["chart"]["chordpro"])
for name in song["stems"]:
stem = check(session.get(f"{API}/songs/{song['id']}/stems/{name}", timeout=120))
with open(f"{name}.m4a", "wb") as f:
f.write(stem.content)With a link instead of a file, send JSON:
check(
session.post(
f"{API}/songs",
json={
"title": "Antsoantso",
"language": "mg",
"lyrics": "",
"audioUrl": "https://example.com/antsoantso.mp3",
"webhookUrl": "https://example.com/hooks/valiha",
"options": {"stems": 2, "melody": []},
},
timeout=120,
)
)A webhook receiver with Flask, using is_from_valiha from Check the signature:
import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/hooks/valiha")
def valiha_webhook():
raw_body = request.get_data()
if not is_from_valiha(raw_body, request.headers, os.environ["VALIHA_WEBHOOK_SECRET"]):
return "", 401
payload = request.get_json()
print(payload["event"], payload["song"]["id"], payload["song"]["status"])
return "", 204