Give 20% off, earn 20% back on every song you share Get your link β†’
Commercial beta

The Ditty City API

Order a personalized song, its matching e-card and its lyrics from your own software, and get back a page you can send to a customer. Built for the systems that already know whose birthday it is: CRMs, loyalty and benefits platforms, retailers bundling a gift with an order.

Getting access

Keys are issued by hand while the API is in beta, so tell us what you are building and we will set you up with a key, a monthly limit and a commercial-use licence. There is no self-serve signup yet, and no charge for a trial key.

Request an API key

Using an AI assistant to write your integration? Point it at https://dittycity.com/api/openapi.json, the full machine-readable (OpenAPI 3.1) description of everything on this page.

How it works

Writing and singing a song takes a few minutes, so the API is asynchronous throughout. You post a brief and get an id immediately; you poll that id until its status is ready. Every response is JSON, including every error.

POST /v1/ditties            -> 202  { id, status: "pending",  share_url }
GET  /v1/ditties/{id}       -> 200  { id, status: "processing" }
GET  /v1/ditties/{id}       -> 200  { id, status: "ready", song: { title, duration_sec, lyrics,
                                          audio_url, card_image_url, urls_expire_at, warnings } }

The share_url is the product: a page carrying the card, the player and the lyrics. It exists from the moment the ditty does, so you can store it against a customer record straight away β€” it starts playing once the status is ready.

Every ditty is the full song: about four minutes, ending with a sign-off from the sender by name when you give us one. duration_sec is approximate (the length the lyrics were written for); the audio file itself is the authority.

To play the song or show the card inside your own product, a ready ditty also carries audio_url (the MP3) and card_image_url (the card picture). These are direct links that expire after 24 hours, at urls_expire_at. Fetch the ditty again whenever you need fresh ones, and store the share_url, not these.

Every finished song is checked automatically. warnings lists what the check found on that recording, each with a stable code and a plain-English message: sections_not_sung (the singer skipped part of the song), name_check_failed (we could not clearly hear the name) or extra_words (the singer adds words near the end). An empty list means the check found nothing. A song with a warning is still ready and playable; listen to it before sending, and tell us if you want it made again.

A ready ditty, in full (lyrics shortened; a real song has twelve to fourteen sections):

{
  "id": "7f2c1e9a-3b4d-4c8e-9a1f-2d6b5e8c0a13",
  "object": "ditty",
  "status": "ready",
  "created_at": "2026-10-03T15:31:04.000Z",
  "brief": {
    "recipient": "Sarah", "relationship": "sister", "occasion": "birthday",
    "about": "runs by the canal every morning before work, makes terrible puns, still has the blue mug with the chip in it",
    "style": "upbeat folk", "voice": "female", "sender": "Tom",
    "message": "Happy birthday from your favourite brother", "language": "English"
  },
  "share_url": "https://dittycity.com/s/kQ3v9XbT2mLw8RzA",
  "song": {
    "title": "Sarah by the Canal",
    "duration_sec": 245,
    "lyrics": [
      { "section": "Verse 1", "lines": ["Sarah, the towpath is waking up", "you are two miles in before the sun"] },
      { "section": "Chorus",  "lines": ["Happy birthday, Sarah, pun queen of the morning", "blue mug, chipped and true"] }
    ],
    "audio_url": "https://…/audio/3d9e….mp3?X-Amz-Signature=…",
    "card_image_url": "https://…/cards/3d9e….png?X-Amz-Signature=…",
    "urls_expire_at": "2026-10-04T15:37:12.000Z",
    "warnings": []
  },
  "failure_reason": null
}

And what one warning looks like:

{ "code": "sections_not_sung", "sections": ["Verse 6"],
  "message": "The singer skipped part of the song: Verse 6. The rest of the song is complete. Listen before sending." }

The share_url is unlisted and not indexed. It is a private page for the person the song is about, not a public gallery entry, so nothing about your customer is published anywhere by ordering one.

Authentication

A bearer token on every request. Keys start with dck_live_ and are shown to you once, when they are issued β€” we store only a hash, so we cannot recover one for you. Tell us and we will revoke it and issue another.

curl https://dittycity.com/v1/me \
  -H "Authorization: Bearer dck_live_YOUR_KEY"

Ordering a ditty

Only recipient is required, but the song is written from what you tell us, so the more specific about is, the better the lyrics. A habit with a time of day on it beats an adjective.

curl -X POST https://dittycity.com/v1/ditties \
  -H "Authorization: Bearer dck_live_YOUR_KEY" \
  -H "Idempotency-Key: order-48213" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "Sarah",
    "relationship": "sister",
    "occasion": "birthday",
    "about": "runs by the canal every morning before work, makes terrible puns, still has the blue mug with the chip in it",
    "style": "upbeat folk",
    "voice": "female",
    "sender": "Tom",
    "message": "Happy birthday from your favourite brother"
  }'
FieldMaxNotes
recipient60Required. Who the song is about. Their name is sung throughout.
relationship40Their relationship to the sender: sister, colleague, grandad.
occasion70Free text: birthday, anniversary, thank you, new baby, retirement, and so on.
about1200The details the lyrics are written from. Concrete beats abstract: a habit, a place, a running joke.
style80A musical direction: upbeat folk, gentle piano ballad, 90s pop. Leave it out and we choose.
voicemale or female. Anything else, or leaving it out, lets us choose.
languageThe language the song is written and sung in. Default English. One of the 48 listed below; anything else is treated as English.
sender60Who it is from. The song ends signing off from them by name.
message500The message written inside the card.
notify_email200Optional. We email this address when the ditty is ready.

All fields are strings. Text longer than its Max is cut to that length, not refused. A brief is refused with 400 invalid_request, with a message saying why, when it has no recipient, asks us to imitate a real artist, contains a web link, or contains offensive words. Nothing is spent and nothing counts against your quota when a brief is refused.

language accepts: English, Spanish, French, German, Italian, Portuguese, Dutch, Polish, Swedish, Norwegian, Danish, Finnish, Icelandic, Czech, Slovak, Hungarian, Romanian, Bulgarian, Croatian, Serbian, Slovenian, Macedonian, Bosnian, Albanian, Greek, Estonian, Latvian, Lithuanian, Maltese, Irish, Welsh, Basque, Catalan, Galician, Ukrainian, Chinese, Japanese, Hindi, Korean, Russian, Arabic, Turkish, Indonesian, Tagalog, Vietnamese, Thai, Hebrew, Afrikaans.

Waiting for it: status and polling

statusMeans
pendingAccepted. The lyrics are being written (usually under a minute).
processingThe song is being sung and mixed.
readyFinished. song is filled in and share_url plays.
failedIt could not be made. failure_reason says why. It does not count against your quota; create it again with a new Idempotency-Key.

A ditty is usually ready in 5 to 7 minutes, occasionally up to 15. Poll GET /v1/ditties/{id} every 10 to 15 seconds. If it is still not ready after 20 minutes, stop and tell us the id. Never create it again to "retry" a slow one: that makes a second song and spends a second unit of quota. Pass notify_email if you would rather be emailed than poll.

Idempotency

An Idempotency-Key header is required on every create, because a create spends money. Send any unique string per logical order β€” your own order id works well β€” and send the same one again if you retry. A repeat returns the original ditty with an Idempotency-Replayed: true header, and never makes a second song.

Limits

Each account has a monthly ditty quota and a per-minute request limit; both are set when your key is issued, and GET /v1/me always tells you where you stand. The quota is counted from ditties actually created and resets on the 1st of each calendar month, UTC. A ditty that fails to render does not count against it.

rate_limit (429): wait the number of seconds in the Retry-After header, then carry on. quota_exceeded (429): do not retry; it will keep failing until the 1st. api_error (500): retry with the same Idempotency-Key, which is always safe.

GET /v1/me returns:

{
  "object": "account",
  "client_id": "cli_6bbb5bffa0f74a25",
  "name": "Acme Gifts",
  "key_prefix": "dck_live_3f9a",
  "key_label": "production",
  "quota": { "monthly": 25, "used_this_month": 3, "remaining": 22, "resets": "the 1st of each calendar month, UTC" },
  "rate_limit_per_min": 30
}

A monthly of null means unmetered. GET /v1/ditties takes limit (default 20, max 100) and offset, newest first, and returns:

{ "object": "list", "data": [ { ...a ditty, same shape as GET /v1/ditties/{id}... } ],
  "limit": 20, "offset": 0, "has_more": false }

A complete example

Create, wait, read the result. Node:

// Node 18+. DITTY_KEY holds your key.
const BASE = 'https://dittycity.com';
const H = { Authorization: 'Bearer ' + process.env.DITTY_KEY, 'Content-Type': 'application/json' };
const sleep = s => new Promise(r => setTimeout(r, s * 1000));

const res = await fetch(BASE + '/v1/ditties', {
  method: 'POST',
  headers: { ...H, 'Idempotency-Key': 'order-48213' },   // your own order id
  body: JSON.stringify({ recipient: 'Sarah', relationship: 'sister', occasion: 'birthday',
    about: 'runs by the canal every morning, makes terrible puns', sender: 'Tom' }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
let d = await res.json();

const giveUp = Date.now() + 20 * 60 * 1000;
while (d.status === 'pending' || d.status === 'processing') {
  if (Date.now() > giveUp) throw new Error('Not ready after 20 minutes: ' + d.id);
  await sleep(15);
  const p = await fetch(BASE + '/v1/ditties/' + d.id, { headers: H });
  if (p.status === 429) { await sleep(Number(p.headers.get('Retry-After')) || 30); continue; }
  if (p.status >= 500) continue;                          // ours; try again next round
  if (!p.ok) throw new Error((await p.json()).error.message);
  d = await p.json();
}
if (d.status === 'failed') throw new Error(d.failure_reason);
console.log(d.share_url);              // store this one
console.log(d.song.audio_url);         // expires in 24h
console.log(d.song.warnings);          // [] means the check found nothing

Python:

# Python 3 with requests. DITTY_KEY holds your key.
import os, time, requests

BASE = "https://dittycity.com"
H = {"Authorization": "Bearer " + os.environ["DITTY_KEY"]}

r = requests.post(BASE + "/v1/ditties",
    headers={**H, "Idempotency-Key": "order-48213"},   # your own order id
    json={"recipient": "Sarah", "relationship": "sister", "occasion": "birthday",
          "about": "runs by the canal every morning, makes terrible puns", "sender": "Tom"})
if not r.ok:
    raise RuntimeError(r.json()["error"]["message"])
d = r.json()

give_up = time.time() + 20 * 60
while d["status"] in ("pending", "processing"):
    if time.time() > give_up:
        raise RuntimeError("Not ready after 20 minutes: " + d["id"])
    time.sleep(15)
    p = requests.get(BASE + "/v1/ditties/" + d["id"], headers=H)
    if p.status_code == 429:
        time.sleep(int(p.headers.get("Retry-After", 30)))
        continue
    if p.status_code >= 500:
        continue                                   # ours; try again next round
    if not p.ok:
        raise RuntimeError(p.json()["error"]["message"])
    d = p.json()

if d["status"] == "failed":
    raise RuntimeError(d["failure_reason"])
print(d["share_url"])            # store this one
print(d["song"]["audio_url"])     # expires in 24h
print(d["song"]["warnings"])      # [] means the check found nothing

Endpoints

MethodPathWhat it does
POST/v1/dittiesOrder a ditty. Returns 202 and an id straight away.
GET/v1/ditties/{id}Fetch one, with its status and, once ready, its song and lyrics.
GET/v1/dittiesList yours, newest first. Takes limit and offset.
GET/v1/meYour account, quota and rate limit. Costs nothing; use it to check a key works.

Errors

Always the same shape, so you can write your handling once.

{ "error": { "type": "quota_exceeded", "message": "Monthly quota reached: 500 of 500 ...", "param": null } }
typeStatusMeans
authentication_error401Missing, unknown or revoked key.
permission_error403The account is suspended.
invalid_request400The brief or a header was not accepted. Look at param.
not_found404No ditty with that id on your account.
idempotency_conflict409That Idempotency-Key was already used with a different brief.
quota_exceeded429Monthly quota reached. Resets on the 1st, UTC.
rate_limit429Too many requests this minute.
api_error500Our fault. Nothing is charged; retry.

Rights and licensing

You get a commercial-use licence for the songs, cards and lyrics your account creates, which is what lets you sell them on or include them in a paid product. We licence use rather than assign copyright, because raw AI output is not copyrightable in the US and we will not pretend otherwise. The exact terms come with your key.

Songs are composed with a licensed AI music engine. We do not imitate real artists or clone anyone's voice.

Tell us what you are building

We will come back with a key, a sensible monthly limit and the licence terms. If it is a good fit we will build the bits you are missing.

Request an API key