← SongGifts

API documentation

The SongGifts API produces personalised songs programmatically: send an occasion and a few details, get back finished lyrics and a produced recording. Plain HTTPS and JSON, no mandatory SDK.

Last updated: 2026-09-16

Request access

Keys are issued by hand. A short mail with your use case, expected volume and languages is enough, and access usually takes one business day.

Request access

Introduction

The API mirrors exactly what the website does. You send an occasion, the name of the person the song is for and a handful of concrete details. Out of that comes a full set of lyrics first, then a produced recording with vocals, arrangement and mix. The whole thing usually takes five to ten minutes.

Every request goes to https://api.songgifts.co.uk/v1. The API speaks HTTPS only, accepts JSON and answers with JSON. There is no SDK you have to use: any language that can do HTTP is enough. The samples on this page use curl, Python and Node because those are the three most common cases.

Each domain has its own API base and its own price in the local currency. A key is valid for the domain it was issued for. If you serve several markets, you get several keys, or one key with several domains enabled.

Billing happens per finished song, currently £24.99. Drafts, cancelled requests and regenerations cost nothing.

Access

There is deliberately no self-service signup. We issue keys by hand, because every song carries real production cost and we want to know what the integration is for. In practice that means a short mail and one business day.

Write to songs@maxkuch.com and tell us four things:

  • What you want to build, in two or three sentences.
  • Roughly how much volume per month.
  • Which languages the songs should be sung in.
  • Whether you can receive webhooks or would rather poll.

You then get two keys: a test key prefixed sk_test_ that costs nothing and returns fixed demo recordings, and a live key prefixed sk_live_. Both work immediately, with no per-endpoint activation.

Authentication

Every request carries the key in the Authorization header as a bearer token. Requests without a valid header get a 401 and the error type authentication_error.

bashComplete request with header
curl https://api.songgifts.co.uk/v1/songs \
  -H "Authorization: Bearer $SONG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "occasion": "birthday",
    "recipient_name": "Anna",
    "relationship": "sister",
    "language": "en-gb",
    "mood": "happy",
    "style": "pop",
    "voice": "female",
    "details": "Climbs every weekend, always ten minutes late, calls everyone chef.",
    "callback_url": "https://example.com/hooks/songs"
  }' 

Treat the key like a password: server side only, never in frontend code, never in a public repository. If a key leaks, mail us, we revoke it immediately and issue a new one. An account may hold several active keys so you can rotate without downtime.

Test and live keys share the same endpoints. Whether a request ran in test mode is recorded in the livemode field of every object.

Quickstart

Creating a song is a single call. The response comes back immediately with a song id and the status queued. Everything else happens in the background.

pythonCreate and wait for the preview (Python)
import os, time, requests

API = "https://api.songgifts.co.uk/v1"
HEAD = {"Authorization": "Bearer " + os.environ["SONG_API_KEY"]}

song = requests.post(API + "/songs", headers=HEAD, json={
    "occasion": "wedding",
    "recipient_name": "Lea and Tim",
    "relationship": "friends",
    "language": "en-gb",
    "mood": "romantic",
    "details": "Met at a bike repair shop, dog named Miso, both terrible dancers.",
}).json()

while song["status"] not in ("preview_ready", "complete", "failed"):
    time.sleep(5)
    song = requests.get(API + "/songs/" + song["id"], headers=HEAD).json()

print(song["lyrics"])
print(song["preview_url"])

For simplicity the sample polls every five seconds. In production webhooks are the better route because they save both the open connection and the waiting. Both are supported, webhooks are described further down.

The field that matters most is details. That is where the concrete things about the person go: the nickname, the running joke, the holiday that went wrong. Generic sentences like "she is a warm person" produce generic lines. Three to five concrete details are enough, and they are the difference between nice and genuinely personal.

Endpoints at a glance

MethodPathPurpose
POST/v1/songsCommission a new song.
GET/v1/songs/{id}Retrieve one song with all current fields.
GET/v1/songsList the account’s songs, filterable and paginated.
GET/v1/songs/{id}/lyricsRetrieve the lyrics only, as plain text.
GET/v1/songs/{id}/audioSigned download URL for the preview or the full recording.
POST/v1/songs/{id}/regenerateTrigger a free regeneration.
POST/v1/songs/{id}/checkoutCreate a hosted payment page for the end customer.
POST/v1/songs/{id}/unlockUnlock the song directly and bill it to the account.
GET/v1/optionsAll valid values for occasion, mood, style, voice and language.
GET/v1/accountBalance, limits and enabled domains.
DELETE/v1/songs/{id}Cancel a song that is not finished yet.

Create a song

POST /v1/songs takes the brief and starts work right away. Only three fields are required, everything else has a sensible default or is chosen to fit the occasion.

FieldTypeDescription
stringrequiredThe occasion. Valid values come from /v1/options.
stringrequiredName of the person the song is for. It is used in the lyrics.
stringrequiredConcrete details about the person, 40 to 4000 characters. This field decides the quality of the result.
stringoptionalHow the buyer relates to the recipient, for example sister, colleague, partner.
stringoptionalThe language the song is sung in. Defaults to en-gb.
stringoptionalOverall mood. If omitted we pick one that fits the occasion.
stringoptionalMusical style. If omitted we pick one that fits occasion and mood.
stringoptionalSinging voice. If omitted we pick one that fits the occasion.
stringoptionalA message that should appear in the song.
stringoptionalFree text for anything that fits nowhere else, such as tempo wishes.
stringoptionalHTTPS address events are posted to.
objectoptionalFree key-value pairs, up to 20. Returned unchanged.
javascriptCreate with an idempotency key (Node)
const res = await fetch("https://api.songgifts.co.uk/v1/songs", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SONG_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    occasion: "anniversary",
    recipient_name: "Mara",
    relationship: "partner",
    language: "en-gb",
    mood: "warm",
    details: "Ten years, three apartments, one very loud coffee machine.",
    callback_url: "https://example.com/hooks/songs",
    metadata: { order_id: "A-10423" },
  }),
});

const song = await res.json();
console.log(song.id, song.status);

The call itself costs nothing. Billing starts only when the song is unlocked through /unlock or a paid checkout session.

The song object

Every endpoint that returns a single song returns the same object. Fields that are not settled yet are null and fill in as production proceeds.

jsonRight after creation
{
  "id": "sng_3n8Kd2ZpQv",
  "object": "song",
  "status": "queued",
  "created_at": "2026-09-16T09:41:02Z",
  "occasion": "birthday",
  "recipient_name": "Anna",
  "relationship": "sister",
  "language": "en-gb",
  "mood": "happy",
  "style": "pop",
  "voice": "female",
  "lyrics": null,
  "preview_url": null,
  "audio_url": null,
  "duration_seconds": null,
  "paid": false,
  "price": { "amount": 2999, "currency": "GBP" },
  "metadata": {},
  "livemode": true
}
FieldTypeDescription
stringoptionalUnique identifier, always starts with sng_.
stringoptionalCurrent production state, see the next section.
stringoptionalThe full lyrics with verse and chorus markers. Free, no payment needed.
stringoptionalThe first 45 seconds as MP3. Permanently available, no payment needed.
stringoptionalThe full recording as MP3, signed and valid for 24 hours. Set only after payment.
integeroptionalLength of the finished recording in seconds, usually between 120 and 240.
booleanoptionalWhether the song has been unlocked.
objectoptionalAmount in the smallest currency unit plus currency code, here £24.99.
objectoptionalWhatever you passed at creation, unchanged.
booleanoptionalfalse if the request ran with a test key.
jsonAfter completion and payment
{
  "id": "sng_3n8Kd2ZpQv",
  "object": "song",
  "status": "complete",
  "created_at": "2026-09-16T09:41:02Z",
  "completed_at": "2026-09-16T09:47:35Z",
  "lyrics": "[Verse 1]\nAnna, six in the morning, chalk on your hands ...",
  "preview_url": "https://cdn.songgifts.co.uk/preview/sng_3n8Kd2ZpQv.mp3",
  "audio_url": "https://cdn.songgifts.co.uk/full/sng_3n8Kd2ZpQv.mp3?expires=1789412855&sig=...",
  "duration_seconds": 184,
  "paid": true,
  "price": { "amount": 2999, "currency": "GBP" },
  "metadata": { "order_id": "A-10423" },
  "livemode": true
}

Status values

A song passes through these states in order. It never moves backwards, and complete, failed and cancelled are terminal.

StatusValueMeaning
queuedAccepted, waiting for a free production slot. Normally a few seconds.
writing_lyricsThe lyrics are being written.
lyrics_readyThe lyrics are complete and retrievable. Usually reached after one or two minutes.
generating_audioVocals, arrangement and mix are being produced.
preview_readyThe first 45 seconds are retrievable and the full file is ready.
completePaid and fully delivered.
failedProduction failed for good. Nothing is billed, the error field says why.
cancelledCancelled before completion.

A single failed production attempt does not immediately mean failed. We retry internally several times and give up only when every attempt fails. That makes failed rare, and when it appears it really means: this song is not coming.

Retrieve and list

GET /v1/songs/{id} returns the current state of a song. The endpoint is cheap and may be polled every second as long as you stay within the rate limit.

bashList with a filter and a cursor
curl -G https://api.songgifts.co.uk/v1/songs \
  -H "Authorization: Bearer $SONG_API_KEY" \
  -d status=complete \
  -d limit=20 \
  -d starting_after=sng_3n8Kd2ZpQv

Lists are cursor based. You get at most limit entries, 20 by default and 100 at most, newest first. If has_more is true, pass next_cursor as starting_after on the next call. You can filter by status, occasion, language, paid as well as created_after and created_before.

jsonA list response
{
  "object": "list",
  "data": [
    { "id": "sng_9Wq1LmT4bR", "status": "complete", "recipient_name": "Jonas", "...": "..." },
    { "id": "sng_3n8Kd2ZpQv", "status": "complete", "recipient_name": "Anna",  "...": "..." }
  ],
  "has_more": true,
  "next_cursor": "sng_3n8Kd2ZpQv"
}

Lyrics and audio

The lyrics are free and complete, not an excerpt. GET /v1/songs/{id}/lyrics returns them as text/plain, with markers for verses and chorus. The same text sits in the lyrics field of the song object.

Audio comes in two stages. The preview is the first 45 seconds of the finished recording, not a separate demo: same voice, same arrangement, same lyrics. It is available without payment and stays that way. The full file comes from GET /v1/songs/{id}/audio once the song is unlocked.

Both addresses are signed and valid for 24 hours. They are meant for downloading, not for permanent linking. If you need a file for longer, download it once and store it on your side. Calling the endpoint again produces a fresh address at any time.

The format is MP3 at 320 kbit/s throughout. If you need WAV, append ?format=wav, which is available to accounts with the studio option enabled.

Regenerate

If a result does not land, regenerating costs nothing. POST /v1/songs/{id}/regenerate produces a new version under the same id and resets the status to queued. The previous version is kept under previous_versions.

bashRegenerate with a reason
curl https://api.songgifts.co.uk/v1/songs/sng_3n8Kd2ZpQv/regenerate \
  -H "Authorization: Bearer $SONG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keep_lyrics": false,
    "reason": "voice_not_matching",
    "note": "Please try a lower male voice and a slower tempo."
  }' 

With keep_lyrics: true the text stays and only the recording is redone. That is the right route when the lyrics work and only the voice or the tempo was off. With false the lyrics are rewritten too.

The note field feeds straight into the regeneration, so a concrete sentence pays off. "Deeper male voice, slower" works, "make it better" does not. Three regenerations per song are free, beyond that talk to us.

Payment and unlocking

There are two ways to unlock a song, depending on who pays.

The end customer pays

POST /v1/songs/{id}/checkout creates a hosted payment page in the currency of the domain, including the payment methods that are common in that country. You send the customer there and receive the song.paid event once payment succeeds.

bashCreate a payment page
curl https://api.songgifts.co.uk/v1/songs/sng_3n8Kd2ZpQv/checkout \
  -H "Authorization: Bearer $SONG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "success_url": "https://example.com/thanks?song=sng_3n8Kd2ZpQv",
    "cancel_url": "https://example.com/cart"
  }' 
jsonResponse
{
  "object": "checkout_session",
  "song": "sng_3n8Kd2ZpQv",
  "url": "https://pay.songgifts.co.uk/c/cs_live_8Hd2Kq...",
  "amount": 2999,
  "currency": "GBP",
  "expires_at": "2026-09-16T11:41:02Z"
}

You pay

On accounts with consolidated billing, POST /v1/songs/{id}/unlock releases the song immediately and charges £24.99 to the account. No detour through a payment page, which suits your own checkout.

bashUnlock directly
curl https://api.songgifts.co.uk/v1/songs/sng_3n8Kd2ZpQv/unlock \
  -H "Authorization: Bearer $SONG_API_KEY" \
  -X POST

The usage rights are the same either way: non-exclusive, but expressly commercial. You may pass the finished song on, sell it and publish it as part of your own offering.

Valid values

The enumerations for occasion, mood, style, voice and language change occasionally. Instead of hard-coding them, call GET /v1/options and cache the result for a few hours.

jsonResponse
{
  "object": "options",
  "language": "en-gb",
  "occasions": ["birthday", "wedding", "anniversary", "farewell", "funeral",
                "christening", "graduation", "christmas", "declaration", "other"],
  "moods":     ["happy", "warm", "funny", "romantic", "gentle", "epic", "surprise_me"],
  "styles":    ["pop", "rock", "folk", "schlager", "hiphop", "ballad", "country",
                "electronic", "jazz", "childrens", "surprise_me"],
  "voices":    ["female", "male", "duet", "choir", "childrens", "surprise_me"],
  "languages": ["de", "en", "dk", "nl", "it", "se", "fr", "es", "no", "pl", "fi", "is", "jp"]
}

Every one of these values may also be left out. The value surprise_me is not a placeholder but a real instruction: we then deliberately pick something that fits the occasion and the details.

Webhooks

Pass a callback_url at creation and we post every event there. This is the recommended route because it saves both polling and waiting.

EventTypeFires when
song.lyrics_readyThe lyrics are complete.
song.preview_readyThe 45 second preview is available.
song.completedThe full recording has been delivered.
song.failedProduction has failed for good.
song.regeneratedA regeneration has finished.
song.paidPayment has arrived and the song is unlocked.
jsonExample payload
{
  "id": "evt_5Tb7Rn2WqX",
  "object": "event",
  "type": "song.completed",
  "created_at": "2026-09-16T09:47:35Z",
  "data": {
    "object": {
      "id": "sng_3n8Kd2ZpQv",
      "object": "song",
      "status": "complete",
      "audio_url": "https://cdn.songgifts.co.uk/full/sng_3n8Kd2ZpQv.mp3?expires=1789412855&sig=...",
      "...": "..."
    }
  }
}

Verify the signature

Every delivery carries a header with a timestamp and an HMAC-SHA256 over timestamp, a dot and the raw body. Verify it before you trust the content, and discard anything older than five minutes.

httpSignature header
X-Song-Signature: t=1789412855,v1=7f2c1d9a4b6e8035c1f7a29d4e5b0c8371a6d2f94e8b3c07a15d9e2f6b4c8a01
pythonVerification in Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

SECRET = os.environ["SONG_WEBHOOK_SECRET"].encode()
app = Flask(__name__)


@app.post("/hooks/songs")
def hook():
    header = request.headers.get("X-Song-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    timestamp, signature = parts.get("t", ""), parts.get("v1", "")

    if abs(time.time() - int(timestamp or 0)) > 300:
        abort(400)  # older than five minutes, treat as replay

    expected = hmac.new(
        SECRET, (timestamp + "." + request.get_data(as_text=True)).encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(400)

    event = request.get_json()
    if event["type"] == "song.completed":
        store(event["data"]["object"])

    return "", 200

Retries

We expect a 2xx response within ten seconds. If it does not arrive we retry eight times over 24 hours with growing gaps. Deliveries can therefore repeat and, rarely, arrive out of order, so make your endpoint idempotent and trust created_at rather than arrival time.

Idempotency

Every POST accepts an Idempotency-Key header with any unique value, usually a UUID. If the same key arrives again within 24 hours we return the original response instead of creating a second song.

bashA retry-safe request
curl https://api.songgifts.co.uk/v1/songs \
  -H "Authorization: Bearer $SONG_API_KEY" \
  -H "Idempotency-Key: 9f1c7c2e-0a3b-4c8d-9e21-5f7a1b6c3d40" \
  -H "Content-Type: application/json" \
  -d '{ "occasion": "birthday", "recipient_name": "Anna", "language": "en-gb", "details": "..." }' 

This is exactly the protection you want against network failures: if a response is lost and your code retries, still only one song exists. Send the same key with a different body and we answer 409 with the error type conflict.

Errors

Errors always come in the same shape, with a machine-readable type and code, a readable message and, where it helps, the field at fault. The request_id belongs in every support mail so we can find the request in the logs.

jsonError object
{
  "error": {
    "type": "validation_error",
    "code": "details_too_short",
    "message": "details must contain at least 40 characters so the song has something to work with",
    "param": "details",
    "request_id": "req_2Lm9Xc4Kd1"
  }
}
TypeHTTPMeaning
400invalid_requestThe request is structurally broken, for example invalid JSON or an unknown field.
401authentication_errorThe key is missing, expired or revoked.
403permission_errorThe key is valid but not allowed on this domain or endpoint.
404not_foundThe identifier does not belong to this account or does not exist.
409conflictThe action does not fit the state, for example unlocking a cancelled song.
422validation_errorThe request is well formed but a value is unusable, for example details that are too short.
429rate_limitToo many requests or too many concurrent productions.
500api_errorA fault on our side. Retry with growing gaps.

Retrying makes sense on 429 and 5xx, ideally with exponential backoff and a little jitter. On any other 4xx it does not: the same request will fail again.

Limits

LimitValueApplies to
60 / minRequests per minute per key across all endpoints.
10Productions running at the same time. Further requests wait in the queue.
64 KBMaximum size of a request body.
40 - 4000Characters in the details field, minimum and maximum.
90Days we keep songs and inputs, after which they are deleted.
24 hWindow in which an idempotency key replays the earlier response.

Every response carries the current state in its headers, so you never have to guess.

httpRate limit headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1789412880
X-Concurrent-Limit: 10
X-Concurrent-Running: 3

Higher limits are not a problem, they are just not the default. When your volume grows, drop us a line and we raise them.

Versioning

The major version lives in the path and stays stable. Within v1 changes are additive only: new fields, new enum values, new endpoints. Existing fields do not disappear and do not change meaning.

If you want extra safety, pin a date in a header. Without the header you always get the newest behaviour.

httpPin the version
X-Song-Version: 2026-09-01

Your code should ignore unknown fields in responses rather than break on them. That is the only assumption we make about clients.

Test mode

Keys prefixed sk_test_ run through exactly the same endpoints but trigger no real production and cost nothing. Within seconds you get fixed demo lyrics and a demo recording, and every object carries livemode: false.

That also lets you rehearse the unpleasant cases. Certain names in recipient_name force a particular outcome: test_fail leads to failed, test_slow to a production of about ten minutes, test_ratelimit to a 429. So you can test your error handling without waiting for a real outage.

Webhooks work in test mode too, with the same signature scheme and a separate secret.

Rights and data

Unlocking gives you a non-exclusive but expressly commercial right to use the finished song. You may pass it on, sell it, play it publicly and embed it in your own product. Non-exclusive means we keep the right to use the recording ourselves, for example as an example.

The copyright status of AI-generated music is not settled in many jurisdictions. We grant you the use contractually, but we cannot promise that a separate copyright arises in the recording that you could enforce against third parties. If you depend on that, have it checked first.

We keep inputs and finished songs for 90 days, then delete them. To delete a single song earlier, use DELETE /v1/songs/{id}. What you send in details is used only to produce that one song and never to train our own models.

If you send us data about your customers, you are the controller and we are the processor. A data processing agreement is available on request.

Support

Questions, higher limits, a data processing agreement, special cases: songs@maxkuch.com. For technical problems quote the request_id from the error response so we can find the request straight away.

For integrations through AI agents there is also a Model Context Protocol server. It is documented at /mcp/ and uses the same keys as the REST API.

Request access

Keys are issued by hand. A short mail with your use case, expected volume and languages is enough, and access usually takes one business day.

Request access