Melodreams Developer API
A small, read-only HTTP API for reading Melodreams profile data and your own analytics from your own server. Six endpoints, three scopes, one response envelope, and a stable set of error codes you can switch on.
Overview #
The Developer API exists so that a service you run can read Melodreams data
without scraping melo.bio or driving a browser session. It is
the same data the public profile page serves, plus your own profile settings
and your own view and click counters, delivered as predictable JSON.
Typical uses: mirroring your link list onto your own website, syncing your bio into a Discord bot, pulling weekly analytics into a spreadsheet or a Grafana panel, or building an internal dashboard across several artist accounts you own.
What this API is not #
The boundaries below are deliberate design decisions, not gaps waiting to be filled. Please read them before you plan an integration around something that is not here.
-
No writes. At all. Every endpoint is
GET. There is no route in the API that creates, updates or deletes anything — not your profile, not your links, not your theme. Editing stays in the dashboard so that per-plan caps (link counts, allowed frames and effects, scheduling) are enforced in exactly one place and cannot drift between two implementations. -
No webhooks or push events. Nothing calls your server. If you need to react to a change, poll — and respect the cache headers described in Caching & revocation.
-
No bulk export and no enumeration. There is no "list all profiles", no search, no cursor and no pagination. Public profiles are fetched one username at a time, and a private or non-existent handle returns the same
profile_not_foundso the endpoint cannot be used to discover which handles exist. A token may also read only a bounded number of distinct profiles per day — see Bulk access & scraping. -
No other user's private data. Ever.
public:readreturns strictly what an anonymous browser already sees atmelo.bio/<username>. Email addresses, billing identifiers, session history, access-code hashes and audience data are never selected from the database by this service at all, so they cannot appear in a response by accident. Other people's analytics are not reachable with any scope. -
No OAuth, no "sign in with Melodreams". Tokens are personal credentials that you mint for your own integrations. There is no flow for asking another Melodreams user to authorise your app.
New scopes are additive — an existing token simply will not carry them until you mint a new one. If a read you need is not here, say so from the portal; it is cheaper for us to add a read than for you to build a scraper.
Base URL & conventions #
https://api-dev1.melodreams.com
HTTPS only — plain HTTP is upgraded, never served. Every endpoint is a
GET, returns application/json; charset=utf-8, and
carries X-Content-Type-Options: nosniff.
Response envelope #
Every /v1/* response uses one of exactly two shapes. You never have
to branch on which endpoint produced an error.
{
"ok": true,
"data": { }
}
{
"ok": false,
"error": "insufficient_scope",
"message": "This endpoint requires the \"analytics:read\" scope. This token has: public:read.",
"docs": "https://developer.melodreams.com/docs#insufficient_scope"
}
- error
- string The contract. A stable, machine-readable snake_case code that will not change once shipped. Switch on this.
- message
- string For humans and logs. May be reworded at any time, and is often more specific than the generic text in the error reference. Never parse it.
- docs
- string A deep link into this page, anchored on the error code.
/health
The liveness endpoint is not enveloped — it returns its fields at the top
level next to ok. It sits outside /v1 precisely
because it is not part of the versioned contract.
Other conventions #
- Timestamps are ISO 8601 in UTC, e.g.
2026-07-22T09:58:12.004Z. That includesX-RateLimit-Reset, which is not a Unix epoch. - Usernames are case-insensitive in the path and are lowercased before lookup. They must match
^[a-z0-9_-]{3,30}$once lowercased, or you getinvalid_username. - Unknown fields are dropped, not passed through. The profile shapes below are a deliberate projection of internal storage, so an editor change in the dashboard is not a breaking API change for you.
- A known path called with the wrong method returns
404 not_found, not405. The API exposes onlyGETand does not advertise anything else. - Versioning is in the path. A breaking change ships as
/v2;/v1keeps its shape. See Deprecation for how long that lasts. - CORS is closed. The only browser origin permitted is the developer portal itself — see Security rules.
Deprecation #
An integration is only worth building if you know how long it lasts. So here are the numbers, rather than a promise to be reasonable.
- A published version runs for at least 24 months from the day its successor ships. If
/v2launches,/v1keeps answering for two more years at full limits. We can extend that; we will not shorten it. - You get 180 days' notice by email before any endpoint, field or scope is removed or changes meaning, sent to the address on the account that owns the token.
- Deprecated responses say so in the headers. Once a version is deprecated, every response carries
DeprecationandSunset(both HTTP-date, per RFC 8594) plus aLinkheader withrel="deprecation"pointing at the migration notes. You can detect it in code without reading this page. - Adding is not breaking. New fields, new endpoints, new optional query parameters and new scopes ship without notice. Parse defensively: unknown fields should be ignored, not fatal.
- These are breaking: removing or renaming a field, narrowing a type, changing the meaning of an existing value, adding a required parameter, tightening a limit, or removing a scope from an existing token's grant.
- Error codes are frozen. A shipped
errorvalue never changes meaning. Onlymessagegets reworded.
If we ever have to break one of these for a security reason, we will say that is what happened, name the reason, and give you as much of the window as the situation allows.
Authentication #
Every /v1 endpoint except /v1/scopes
requires a bearer token. Tokens are personal, long-lived credentials tied to
one Melodreams account.
Token anatomy #
A token is a single opaque string of 75 characters. It is built from four parts, shown below so you can recognise one on sight and tell two of your own tokens apart — but your code should never take it apart. The example is illustrative and not a working credential.
-
mdr
Vendor prefix. Constant for every Melodreams token. It exists so a leaked token is greppable in a repository scan and so the pattern can be registered with automated secret scanners.
-
live
Environment label, and currently a constant: every Melodreams token is a
livetoken. There is no sandbox environment — see There is no sandbox for why, and for what to do during development instead. -
Zk9xQ2pM7vTb1sYwLd4NrA
The public half: base64url of 16 random bytes. Stored in plaintext and indexed, so verifying a token is one indexed lookup rather than a scan. Safe to display and safe to log — the portal shows
mdr_live_Zk9xQ2pM7vTb1sYwLd4NrAso you can tell two tokens apart. -
8pQv…VwXyz
The secret half: base64url of 32 random bytes. Never stored. Only
sha256(secret + server-side pepper)is written to the database, and the pepper is not in the database. Shown to you exactly once, at creation. Nobody at Melodreams can recover it.
_.
The token id and the secret are base64url, and the base64url
alphabet includes _ and -. Splitting the string on
_ and expecting four parts is wrong for the majority of tokens,
because most of them contain at least one _ inside the id or the
secret. Our own parser is positional for exactly this reason.
Pass the whole string through unchanged: read it from the environment, put it
in the Authorization header, and never parse, normalise, trim,
lowercase or re-encode it. If you need a short label for logs and dashboards,
take a fixed prefix — the first 31 characters, which is
mdr_live_<token_id> — rather than a split.
Getting a token #
- Sign in at melodreams.com with an account on an active paid plan — see Plan requirement.
- Open the developer portal. The session cookie is shared across
melodreams.com, so there is no second sign-in. - Go to token management and create a token: give it a name (1–60 characters, describing the one integration it is for), pick its scopes, and pick an expiry.
- Copy the token immediately. The full string is displayed once and is never recoverable. If you lose it, revoke it and mint another.
Token management is session-authenticated only. An API token cannot create, list or revoke tokens — including itself. If it could, a single leak would be self-perpetuating and revocation would be meaningless.
Expiry
The portal offers 1 day, 7 days,
30 days (the default), 90 days,
1 year, or never. Over the portal API the field
is expires_in_days: an integer from 1 to 365, or
omitted entirely for a token that lasts until it is revoked.
Pick the shortest expiry your deployment process can live with.
An expiry converts rotation from something you have to remember into something
that happens whether you remember it or not, and it puts a hard ceiling on how
long a leak stays useful. 30 days suits most production integrations; a day or a
week is right for anything you are only experimenting with. "Never" is a real
option, but choose it deliberately — a token with no expiry is one you will still
be trusting in three years, on a server you may no longer own. An expired token
returns 401 token_expired, which is
unambiguous and easy to alert on.
There is no sandbox #
Every token is a live token: mdr_live_…. There is no test environment, no
mdr_test_… counterpart and no set of fixture profiles to develop
against.
This is an omission on purpose rather than a feature that has not shipped yet. A
sandbox earns its keep when calls have consequences — charges to reverse, emails
that would really send, records that would really be written. This API has none
of that: every endpoint is a GET, nothing it does is destructive, and
the only thing a call spends is quota. A second environment would have had to
either mirror live data (in which case it is live data with a different label) or
serve invented data (in which case you would be testing against fiction and your
integration would meet its first real profile in production). Neither is worth a
second set of credentials to lose track of, so the environment was removed rather
than left standing as a promise the API could not keep.
Mint a separate token with a short expiry and the narrowest scopes
that job needs — often one day and public:read alone.
You get the property a test credential was really buying you: something you
can hand to a laptop or a CI runner and revoke on a whim without touching
anything that serves real traffic, and which stops working by itself if you
forget it exists.
Keep the long-lived, wider-scoped token for production only, name the two differently in the portal so they are impossible to confuse, and remember that both count against the same allowance of 5 active tokens. Quota is per token, so a development token cannot eat production's 50,000.
Sending the token #
One transport, no alternatives: an Authorization header with the
Bearer scheme.
curl --silent --show-error \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
--header "Accept: application/json" \
"https://api-dev1.melodreams.com/v1/me"
?token=… and ?api_key=… do nothing. This is not an
oversight: URLs end up in web-server access logs, shell history, browser
history, proxy logs and outbound Referer headers. A credential
in a URL is a credential you have already published. Requests without the
header get 401 missing_token and a
WWW-Authenticate: Bearer challenge.
The cheapest way to confirm a token works is GET /v1/me.
It needs a valid token but no scope at all, and it tells you which account,
environment, scopes and limits the token carries.
Security rules #
A Melodreams API token is a bearer credential: whoever holds the string is you, up to its scopes, until it is revoked. Treat it exactly as you would a database password. The rules below are not boilerplate — each of them corresponds to a real way tokens get lost.
Anything shipped to a browser or an app binary is readable by everyone who
receives it — minification, obfuscation and environment-variable inlining at
build time change nothing. Calls must originate from a server you control.
CORS on this API allows exactly one browser origin (the developer portal),
so a fetch() from your own site will fail. That failure is the
feature, not a bug to work around with a proxy that forwards the raw token.
-
Keep tokens out of source control. Read them from an environment variable or a secret manager, never from a literal in a file. Add
.envto.gitignorebefore you create it, not after. Git history is forever: a token that was committed and then removed in a later commit is still in the repository, and rewriting history does not un-leak it. If it was ever pushed, revoke it. -
Never put a token in front-end JavaScript, a mobile app, a desktop app, a browser extension, or a static site build. If the code runs on a machine you do not control, the token is on a machine you do not control. Put a thin backend of your own in front, hold the token there, and expose only the specific data your client needs.
-
Never paste a token into a URL, an issue, a pull request, a screenshot, a CI log or a chat message. Log your requests without the
Authorizationheader, or redact it. If you must reference a token in a conversation, use its public prefix (mdr_live_<token_id>) — that half is safe to share. -
One token per integration. Not one per person, not one shared across every script you own. Separate tokens make revocation surgical: when the staging box is decommissioned or a contractor's laptop goes missing, you kill one token and nothing else stops working. You can hold up to 5 active tokens, which is enough for a handful of well-separated integrations and few enough to keep track of.
-
Grant the narrowest scopes that work. A token that only mirrors public link lists needs
public:readand nothing else. Addinganalytics:read"just in case" only widens what a leak costs you. Scopes are fixed at creation — to change them, mint a new token and revoke the old one. -
Develop against a short-lived, narrowly-scoped token of its own. There is no sandbox and no
mdr_test_…environment — see There is no sandbox. So do not point your laptop, your CI runner or your staging box at the production token. Mint them one of their own with a one-day or seven-day expiry and only the scopes that job actually needs, and let it expire on its own. That is a credential you can revoke on a whim without touching anything serving real traffic, and one that stops being dangerous by itself if you forget it exists. -
Rotate on a schedule, and always on a personnel change. Set an expiry when you create a token — 1, 7, 30, 90 or 365 days — so rotation is enforced rather than remembered. Prefer the shortest one your deployment process can absorb; "never" should be a decision, not a default. To rotate with no downtime: mint the new token, deploy it, confirm traffic has moved using
last_used_atin the portal, then revoke the old one. -
If a token might be exposed, revoke it first and investigate afterwards. Revocation is free and instantaneous to perform. There is no "maybe" worth keeping a suspect credential alive for. Revoked tokens stop working everywhere within 30 seconds — see Revocation timing.
What Melodreams stores, and what it cannot do #
| Item | How it is handled |
|---|---|
| token_id | Stored in plaintext and indexed. It is the public half — it identifies the token but grants nothing on its own. |
| secret | Never stored, in any form that can be reversed. Only sha256(secret + pepper) is written. The pepper is a server-side secret held outside the database, so a stolen database dump alone cannot be used to verify or forge tokens offline. |
| last_used_ip_hash | A salted hash, truncated. Raw IP addresses are never stored. |
| Usage | Aggregate counters only: a monthly request count and a per-day count for the last 30 days. No per-request rows, no paths, no user agents, no IPs. |
There is no "show token again", no support path, and no administrative override — the plaintext secret does not exist anywhere after the response that created it. If you lose a token, the only remedy is to revoke it and create a new one. This is the property that makes the store safe; it is not a limitation we would remove.
Plan requirement #
A free account can sign in to the developer portal — it needs to, to see why it has no access — but it cannot mint a token and cannot use one.
Plan status is resolved live, on every single request, against the account that owns the token. It is not baked into the token at creation time. That distinction matters: scopes are a property of the token and outlive the subscription, so a token minted while you were subscribed would otherwise keep working forever. One month of subscription would buy permanent API access.
When the account behind a token is no longer on an active plan, every
/v1 request — including /v1/me — returns
402 plan_required. Nothing about the
token itself changes; it starts working again the moment the subscription does.
Failed payments: a 7-day grace window #
A declined card does not break your integration on the spot. An account whose
subscription is past_due stays in a past_due_grace
state for 7 days after the period end, and during that window the API behaves
exactly as it does on a healthy subscription — full limits, all scopes,
including analytics:read. /v1/me reports
"tier": "past_due_grace", which is worth alerting on: it is your
seven-day warning that the integration is about to stop.
After the grace window closes, the account falls back to free and every request
returns 402 plan_required. Accounts that are deleted return
410 account_deleted; accounts that are suspended, locked or under
review return 403 account_blocked.
Limits & quotas #
The two request limits are independent and enforced by different
mechanisms. The burst limit is a rolling 120 requests per 60
seconds keyed on the token id; exceeding it returns
429 rate_limited and clears on its own
within a minute — retry with backoff. The monthly quota is an
exact count of 50,000 requests per token; exhausting it returns
429 quota_exceeded, which
will not clear by retrying. A third limit counts something else
entirely — how many different profiles a token reads in a day — and is
described under Bulk access & scraping.
The monthly quota is per token, not per account, and resets at
00:00 UTC on the 1st of each calendar month — not on your
billing anniversary. A calendar month is the same for everyone, is predictable
without reading your subscription, and does not shift when you change plan. The
reset instant is returned verbatim in X-RateLimit-Reset.
The 120/minute burst limit is the only per-minute limit an authenticated request meets. It is keyed on the token id, not on your IP address, so it does not matter how many hosts your integration runs on or how many of your end users sit behind one NAT egress — one token gets 120 requests a minute, in full.
There is a second limiter of 20 per 60 seconds per source IP
address, and it is consumed by failure only: a request with
no Authorization header, a token that is not structurally a
Melodreams token, and a token that fails authentication. It exists so that
token-guessing and header-less floods are free for us to absorb.
A request that authenticates successfully never touches it, whatever its
source IP, so this is not something a working integration can run into. All
three kinds of failure draw the budget down. Once it is empty, requests that
arrive with no token or an unparseable one are answered with
429 rate_limited instead of the 401 that would have
told you what was wrong; a well-formed but wrong token still gets its own
401, it just keeps spending the budget while it does. Either way
the fix is the credential, not the backoff.
(/v1/scopes and
/health need no token and are not
subject to it.)
Bulk access and scraping #
A single token may read at most 500 distinct profiles per UTC day
through public:read. The 501st new username in a day returns
429 distinct_profile_cap.
The word doing the work there is distinct. The counter is a set of
usernames, not a tally of requests. Once a token has fetched
/v1/profiles/aurora today, every further read of
aurora today is free of this limit and unrestricted — it still costs
one request against your monthly quota and still passes through the burst limit,
but it does not count again here. A widget that refreshes the same 20 profiles
every minute all day long touches 20 of your 500 and will never see this error.
Only breadth is limited, never depth.
| Request | Counts? | Why |
|---|---|---|
| GET /v1/profiles/aurora | First today | A username this token has not read today. Adds one to the set. |
| GET /v1/profiles/aurora | No | Already in today's set. Unlimited from here until 00:00 UTC. |
| GET /v1/me/profile | No | Your own data, read with profile:read. Never counted. |
| GET /v1/me/analytics | No | Your own data, read with analytics:read. Never counted. |
| GET /v1/me | No | Carries no username at all. |
Why this limit exists
Every field this API returns for another user is already public: it is what
melo.bio/<username> serves to an anonymous browser, and we are
happy for you to read it. What is public one profile at a time is not
therefore available as a dataset. Assembling a copy of the Melodreams
userbase — every handle, every bio, every off-platform link, in one file — is not
a use case this product supports, and it is not made legitimate by being done one
HTTP request at a time. Our users publish a page; they do not publish a row in
somebody else's database.
So the limit is deliberately shaped to be invisible to the integrations this API was built for and immediately fatal to the one it was not. Mirroring your own links, syncing a handful of artist profiles you manage, powering a Discord bot for a label roster, running a dashboard over the accounts you own — all of these touch tens of profiles, not thousands, and none of them will ever see this error. Walking the userbase hits the wall on day one and gains 500 rows a day thereafter, which is not a rate anybody builds a scraper for.
High-volume reads are not forbidden, they are just not self-serve. If your use case genuinely needs breadth — a research project, a partner integration, an internal tool across a large roster — contact support from the developer portal and describe what you are building and what you will do with the data. That conversation is much shorter than the one that starts with us noticing the traffic. Minting a second token to get a second 500 is not a workaround; it is the thing the conversation would have been about.
Behaviour in detail
- The set is per token, not per account, and it resets at 00:00 UTC — the same instant for everyone, regardless of where you or your users are.
- The error body's
messagereports how many distinct profiles the token has read today and what the cap is, as context. Like everymessage, it is for humans and logs — never parse it. Branch onerror === "distinct_profile_cap". - The rejected request does not consume monthly quota, and the username it asked for is not added to the set. The quota headers are present on this response, unlike on a burst
rate_limited. - A username that does not exist still counts. The set is updated before the profile is looked up, so a handle that returns
profile_not_foundhas already spent one of the 500. This is on purpose: guessing at handles is exactly the behaviour the limit is here to stop. - Retrying does not help, and retrying a different username helps even less. Treat it like
quota_exceeded: alert, and wait for the reset.
Quota headers #
Successful responses — and the two metered 429s,
quota_exceeded and distinct_profile_cap — carry your
remaining monthly budget, so you can monitor consumption without
spending a request on a separate call.
| Header | Example | Meaning |
|---|---|---|
| X-RateLimit-Limit | 50000 | The monthly request quota for this token. Not the per-minute burst limit. |
| X-RateLimit-Remaining | 49713 | Requests left in the current calendar month, after counting this one. |
| X-RateLimit-Reset | 2026-08-01T00:00:00.000Z | When the monthly counter resets. An ISO 8601 timestamp, not a Unix epoch and not a seconds-remaining value. |
- These headers are attached to
2xxresponses and to429 quota_exceededand429 distinct_profile_cap. They are not present on401,402,403,404or burst429 rate_limitedresponses, which are rejected before metering runs. Read them defensively. - If the metering layer is briefly unavailable, requests are allowed through and
X-RateLimit-Remaining/X-RateLimit-Resetare omitted rather than guessed. Authentication fails closed; metering fails open. The asymmetry is intentional — an infrastructure problem on our side should not become a false429storm on yours. - Token count: you may hold up to 5 active tokens. Revoked and expired tokens do not count. Creating a sixth returns
400 bad_requesttelling you to revoke one first.
Scopes #
Scopes are chosen when a token is created and are fixed for its lifetime. A
token carrying none of the scopes an endpoint needs gets
403 insufficient_scope, and the
message tells you which scope was required and which the token
actually has.
Every scope in v1 is a read. There is no profile:write, no
audience:* and no webhook scope, because there is no code path in
the API that mutates or exports anything. Scope strings never change meaning —
if a capability widens, it ships as a new scope string so a token issued under
the old meaning cannot silently gain access.
| Scope | Grants | Reads | Paid feature |
|---|---|---|---|
| public:read | Read any public Melodreams profile. Exposes nothing that melo.bio/<username> does not already serve to an anonymous browser. This is the only scope subject to the distinct-profile cap. |
Anyone's public data | No |
| profile:read | Read the token owner's own profile, links, theme and visibility settings. | Owner only | No |
| analytics:read | Read the token owner's profile view and link click counters. | Owner only | Yes |
The "paid feature" column marks scopes that are gated by plan in the product
itself — analytics is not available to free accounts anywhere. It is a separate
axis from the plan requirement, which applies to the whole
API regardless of scope. The live catalogue is served by
GET /v1/scopes.
Endpoints #
Six endpoints. All GET. All read-only.
| Endpoint | Auth | Scope |
|---|---|---|
| GET /health | None | — |
| GET /v1/scopes | None | — |
| GET /v1/me | Token | Any |
| GET /v1/profiles/{username} | Token | public:read |
| GET /v1/me/profile | Token | profile:read |
| GET /v1/me/analytics | Token | analytics:read |
Health #
Unauthenticated liveness check. Use it for uptime monitoring and for confirming reachability from a new network before you debug credentials. It reveals readiness and nothing else, and it costs no quota.
GET /v1/health is an alias for the same handler.
This is the one endpoint that is not wrapped in the
data envelope.
Example request
curl --silent "https://api-dev1.melodreams.com/health"
Example response 200
{
"ok": true,
"service": "developer-api",
"version": "v1",
"cache": {
"entries": 412,
"max": 5000
},
"timestamp": "2026-07-22T10:31:04.118Z"
}
Scope catalogue #
The machine-readable scope catalogue, exactly as the portal and this page render it. Public and unauthenticated because it is not sensitive, and useful if you are generating a permissions UI of your own. Costs no quota.
Example request
curl --silent "https://api-dev1.melodreams.com/v1/scopes"
Example response 200
{
"ok": true,
"data": {
"scopes": [
{
"scope": "public:read",
"description": "Read any public Melodreams profile.",
"requires_subscription": false
},
{
"scope": "profile:read",
"description": "Read the token owner's own profile, links and theme.",
"requires_subscription": false
},
{
"scope": "analytics:read",
"description": "Read the token owner's profile view and link click counters.",
"requires_subscription": true
}
]
}
}
Token identity #
Who this token belongs to, what it can do, and what its limits are. Every valid token may call it, whatever its scopes — this is the endpoint an integration should call at start-up to verify its credentials, and the first thing to try when something is not working.
Response fields
- user_id
- string (uuid) The Melodreams account that owns the token.
- username
- string | null The owner's handle.
- org_id
- string (uuid) | null Set only for organisation-scoped tokens.
- account_type
- string
userororg. - tier
- string The live access tier:
fullorpast_due_grace. Anything else and the request would not have reached this handler. - subscribed
- boolean True while the plan requirement is satisfied, including during the grace window.
- token.id
- string The public token id — segment 3 of the token. Safe to log.
- token.environment
- string Always
live. There is no other environment — see There is no sandbox. - token.scopes
- string[] Sorted, de-duplicated.
- limits
- object
requests_per_minuteandrequests_per_monthfor this token.
Example request
curl --silent \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
"https://api-dev1.melodreams.com/v1/me"
Example response 200
{
"ok": true,
"data": {
"user_id": "8f2b1c44-7d6e-4a19-b3c0-5e91d2a7f004",
"username": "aurora",
"org_id": null,
"account_type": "user",
"tier": "full",
"subscribed": true,
"token": {
"id": "Zk9xQ2pM7vTb1sYwLd4NrA",
"environment": "live",
"scopes": ["analytics:read", "profile:read", "public:read"]
},
"limits": {
"requests_per_minute": 120,
"requests_per_month": 50000
}
}
}
Public profile #
Any public Melodreams profile by handle. The response is a stable projection of the profile — fields the API does not document are dropped rather than passed through, so a field you see here will still be here next month.
Path parameters
- username
-
string, required
Case-insensitive; lowercased before lookup. Must match
^[a-z0-9_-]{3,30}$once lowercased, otherwise400 invalid_username.
A handle that does not exist and a handle whose profile is not public both
return 404 profile_not_found.
The two cases are deliberately indistinguishable so the endpoint cannot be
used to enumerate private handles.
This is the only endpoint subject to the distinct-profile cap: a token may
read 500 different usernames per UTC day, after which a new one
returns 429 distinct_profile_cap.
Re-reading a username you have already fetched today is not limited. See
Bulk access & scraping.
Response fields
- username
- string
- display_name
- string | null
- bio
- string | null
- avatar_url, banner_url
- string | null
- theme
- string | null The profile's theme identifier.
- avatar
- object
shape,border,border_color,frame,frame_color. - links[]
- array Each entry has
labelandurl, plus optionalicon,categoryandthumbnail. Links with no destination are omitted. Editor internals — ordering keys, ids and scheduling windows — are never included. - socials[]
- array Each entry has
platformandvalue. - flags
- object
access_code_required,status_dot,discoverable,listable. A gated profile is reported as a boolean only; the access-code hash and salt are never read from storage at all. - custom_domain
- string | null
- melo_bio_mode
- string Defaults to
both. - updated_at
- string (ISO 8601) | null
Example request
curl --silent \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
"https://api-dev1.melodreams.com/v1/profiles/aurora"
Example response 200 · Cache-Control: public, max-age=60
{
"ok": true,
"data": {
"username": "aurora",
"display_name": "Aurora",
"bio": "Producer. Night shift. Mixing in Berlin.",
"avatar_url": "https://img.melodreams.com/u/aurora/avatar.webp",
"banner_url": null,
"theme": "midnight",
"avatar": {
"shape": "circle",
"border": true,
"border_color": "#3b82f6",
"frame": "none",
"frame_color": null
},
"links": [
{
"label": "Latest release",
"url": "https://open.spotify.com/album/4kaurora",
"icon": "spotify",
"category": "music"
},
{
"label": "Tour dates",
"url": "https://aurora.example/tour"
}
],
"socials": [
{ "platform": "instagram", "value": "auroraplays" },
{ "platform": "soundcloud", "value": "aurora" }
],
"flags": {
"access_code_required": false,
"status_dot": true,
"discoverable": true,
"listable": true
},
"custom_domain": null,
"melo_bio_mode": "both",
"updated_at": "2026-07-19T21:04:33.512Z"
}
}
Own profile #
The token owner's own profile. Identical in shape to
the public projection, with one extra
visibility object describing settings that have no business
being exposed about someone else's profile. Works whether or not the
profile is currently public.
Additional fields
- visibility.is_public
- boolean Whether the profile is served publicly at all.
- visibility.custom_domain_redirect
- boolean
- visibility.reserved_style
- string Defaults to
hidden.
Never shared-cacheable: this response is specific to one token and is sent
with Cache-Control: no-store. If the account has no profile row
yet, the endpoint returns 404 profile_not_found.
Example request
curl --silent \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
"https://api-dev1.melodreams.com/v1/me/profile"
Example response 200 — abridged
{
"ok": true,
"data": {
"username": "aurora",
"display_name": "Aurora",
"bio": "Producer. Night shift. Mixing in Berlin.",
"avatar_url": "https://img.melodreams.com/u/aurora/avatar.webp",
"banner_url": null,
"theme": "midnight",
"avatar": {
"shape": "circle",
"border": true,
"border_color": "#3b82f6",
"frame": "none",
"frame_color": null
},
"links": [
{ "label": "Latest release", "url": "https://open.spotify.com/album/4kaurora" }
],
"socials": [
{ "platform": "instagram", "value": "auroraplays" }
],
"flags": {
"access_code_required": false,
"status_dot": true,
"discoverable": true,
"listable": true
},
"custom_domain": null,
"melo_bio_mode": "both",
"updated_at": "2026-07-19T21:04:33.512Z",
"visibility": {
"is_public": true,
"custom_domain_redirect": false,
"reserved_style": "hidden"
}
}
}
Own analytics #
The token owner's profile view count and link click counters. There are no query parameters, no date ranges and no filters: this endpoint returns the current lifetime counters, and it is the same data the dashboard shows you.
Analytics is a paid capability in the product, so this endpoint re-checks the live plan on every request in addition to the scope check. A brand-new profile with nothing recorded yet returns zeros, not a 404.
Response fields
- views
- integer Total profile views.
- clicks.total
- integer Total link clicks.
- clicks.by_link[]
- array
{ label, count }, sorted bycountdescending and capped at the top 100 links, so the response size is bounded. - updated_at
- string (ISO 8601) | null When the counters were last written.
The dashboard's hourly breakdown is deliberately not exposed. It is an unbounded internal map used for charting, and publishing its key format would freeze an implementation detail into the API contract.
Example request
curl --silent --dump-header - \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
"https://api-dev1.melodreams.com/v1/me/analytics"
Example response 200
{
"ok": true,
"data": {
"views": 18422,
"clicks": {
"total": 4310,
"by_link": [
{ "label": "Latest release", "count": 2871 },
{ "label": "Tour dates", "count": 1102 },
{ "label": "Merch", "count": 337 }
]
},
"updated_at": "2026-07-22T09:58:12.004Z"
}
}
Caching & revocation #
Response caching #
| Endpoint | Cache-Control |
|---|---|
| GET /v1/profiles/{username} | public, max-age=60, s-maxage=60, stale-while-revalidate=300 |
| Everything else | no-store |
Public profile data is already served to anonymous browsers, so it is safe for
a shared cache and is marked cacheable for 60 seconds — the same window the
melo.bio edge cache uses. Everything else is token-specific and is
never cacheable by a shared cache. If you are polling public profiles, honour
that header: a well-behaved HTTP client turns a once-a-second poll into one
origin request a minute, and it is free quota for you.
Because a public profile may be served from a 60-second cache, an edit made in the dashboard can take up to a minute to appear through this endpoint.
Revocation timing #
A revoked token can continue to be accepted for up to 30 seconds after you revoke it. So can a plan change, an account suspension, and a token expiry.
This is a deliberate, documented tradeoff rather than an accident. Authenticating a request requires reading the token record and the owning account's state. Doing that from the database on every single call would make database reads the dominant cost of running the API, and that cost would land on the price of the product. Instead, the result of a successful authentication is held in memory for 30 seconds.
What that buys and what it costs, precisely:
- Bounded exposure. 30 seconds, always. It does not extend with traffic and there is no longer path. Revocation is best-effort immediate on the machine that handled it, and guaranteed everywhere within the window.
- Your token list is never stale. Reads of your own tokens in the portal are not cached. When the portal says a token is revoked, it is revoked — the 30 seconds applies only to requests already in flight against warm machines.
- Failures are cached for less. A token that fails verification is remembered as invalid for a shorter window, so a guessing flood cannot turn into a database bill. A dependency error is never cached at all — an upstream blip must not be memoised into 30 seconds of guaranteed failure.
- It applies to plan state too. Subscribing, lapsing and being suspended all take effect within the same window.
The practical consequence for you: if a token is compromised, revoking it is still the right and immediate first action — but assume the attacker has up to 30 more seconds of read access, and treat the data reachable with that token's scopes accordingly.
Error reference #
Errors always use the error envelope. The
error field is the contract: a fixed vocabulary of stable
snake_case codes that will not change once shipped. Branch on it, log it, and
ignore the HTTP status if it is easier — the two never disagree.
The docs field in every error body links directly to the matching
row below.
| Status | Code | What it means and what to do |
|---|---|---|
| 400 | bad_request | The request was malformed. Not retryable without a change. On token creation this is also how "you already hold the maximum number of active tokens" is reported. |
| 400 | invalid_username | The handle in the path is not a valid Melodreams username. It must match ^[a-z0-9_-]{3,30}$ after lowercasing. Validate before calling rather than spending a request. |
| 400 | invalid_scope | One or more requested scopes are not recognised. Raised when creating a token in the portal, not by the read API. Compare against GET /v1/scopes. |
| 401 | missing_token | No Authorization: Bearer header was sent. Note that ?token= is not a supported alternative — see Sending the token. The response carries a WWW-Authenticate: Bearer challenge. |
| 401 | malformed_token | The credential is not structurally a Melodreams token. Usually a truncated copy-paste, a stray quote or whitespace, or a shell that did not expand your environment variable. Check the string is exactly mdr_live_<22>_<43> — 75 characters, unmodified. Note that _ is a legal character inside the id and the secret, so a client that rebuilt the token by splitting on _ lands here. |
| 401 | invalid_token | Structurally valid, but not a token we accept. An unknown token id and a wrong secret return this same code on purpose, so the response cannot be used to probe which token ids exist. |
| 401 | token_revoked | This token was revoked. It will never work again; mint a new one. Not retryable. |
| 401 | token_expired | The token passed the expiry date it was created with. Not retryable — mint a replacement. |
| 401 | session_required | A signed-in Melodreams session is required. Only ever returned by portal token-management routes — an API token cannot manage tokens. You will not see this from /v1. |
| 402 | plan_required | The token is valid but the owning account has no active subscription. See Plan requirement. Retrying will not help; resubscribing will, and takes effect within 30 seconds. |
| 403 | insufficient_scope | The token does not carry the scope this endpoint requires. The message names the required scope and lists what the token actually has. Scopes are immutable — mint a new token with the right scopes. |
| 403 | account_blocked | The account behind the token is not in good standing — suspended, locked, under review, deactivated or restricted. Contact support; there is nothing to fix in your code. |
| 404 | not_found | No such endpoint. Also what you get for a valid path called with a method other than GET — the API deliberately does not return 405. |
| 404 | profile_not_found | No public profile exists for that username. A handle that does not exist and a handle that is not public are indistinguishable, by design. From /v1/me/profile it means the account has no profile row yet. |
| 410 | account_deleted | The account behind the token has been deleted. Terminal. Stop retrying and remove the integration. |
| 429 | rate_limited | Too many requests in a short window: the per-token burst limit of 120/minute. Back off exponentially and retry; it clears within a minute. Also returned when a source IP has exhausted the 20/minute budget for failed authentication and then sends a request with no token or an unparseable one — in which case the real problem is the credential, not the pace. See Limits & quotas. |
| 429 | quota_exceeded | The token's monthly quota is exhausted. Retrying will not help — distinguish this from rate_limited before you back off. X-RateLimit-Reset and the message both carry the reset instant. |
| 429 | distinct_profile_cap | This token has already read 500 distinct profiles today and asked for a 501st. Re-reading a username it has already fetched today still works and is not limited; only new ones are refused. Resets at 00:00 UTC. The message carries the current count and the cap. Retrying will not help, and neither will trying a different username. See Bulk access & scraping. |
| 500 | internal_error | Something went wrong on our side. Internal messages and stack traces are never included. Safe to retry once with backoff; if it persists, report it with the timestamp. |
| 503 | upstream_error | A dependency was unavailable or too slow. Transient — retry shortly with backoff and jitter. |
Recommended retry policy #
- Retry
rate_limited,internal_errorandupstream_error, with exponential backoff and jitter, and a cap on attempts. - Do not retry
quota_exceeded— it will not clear until the 1st of the month. Alert instead. - Do not retry
distinct_profile_capeither, and do not "route around" it by requesting a different username. It clears at 00:00 UTC. If you are hitting it at all, the shape of the integration is the thing to change — see Bulk access & scraping. - Never retry any
4xxother than429. A401,402or403means something needs a human: a new token, a subscription, or a different scope. Retrying just burns quota and delays the alert.
Code examples #
Complete, runnable programs — not fragments. Every one of them reads the token
from the MELODREAMS_API_TOKEN environment variable and exits if it
is missing, passes it through untouched as the opaque string it is, distinguishes
a retryable rate_limited from a pointless retry on
quota_exceeded or distinct_profile_cap, and raises on
any non-ok response instead of silently returning null.
export MELODREAMS_API_TOKEN="mdr_live_..."
With that set, each program runs directly: node melodreams.mjs,
python melodreams.py, php melodreams.php,
go run main.go, ruby melodreams.rb,
./melodreams.sh. The JavaScript one carries the .mjs
extension because it finishes with a top-level await, and it needs
nothing installed — Node 18 and newer ship a global fetch.
Three details are worth reading for in all six. Each decodes the response body
defensively and falls back to an empty object, because a proxy sitting between
you and the API can answer with something that is not JSON at all. Each splits
the 429s: rate_limited is slept on and retried with
exponential backoff, while quota_exceeded and
distinct_profile_cap are raised straight away, since neither clears
by waiting a few seconds. And the shell version delegates its backoff to curl's
own --retry 3 --retry-delay 2 --retry-all-errors, recovering the
status code by asking --write-out to append it as a final line and
then splitting that line back off.
const BASE = 'https://api-dev1.melodreams.com';
const TOKEN = process.env.MELODREAMS_API_TOKEN;
if (!TOKEN) {
console.error('MELODREAMS_API_TOKEN is not set. Refusing to run.');
process.exit(1);
}
class MelodreamsError extends Error {
constructor(code, message, status) {
super(`${code}: ${message}`);
this.name = 'MelodreamsError';
this.code = code;
this.status = status;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function melodreams(path, { retries = 3 } = {}) {
for (let attempt = 0; ; attempt++) {
const response = await fetch(BASE + path, {
headers: {
Authorization: `Bearer ${TOKEN}`,
Accept: 'application/json',
'User-Agent': 'my-integration/1.0',
},
});
const body = await response.json().catch(() => ({}));
if (response.status === 429) {
if (body.error === 'quota_exceeded' || body.error === 'distinct_profile_cap') {
throw new MelodreamsError(body.error, body.message ?? 'not retryable', 429);
}
if (attempt < retries) {
const wait = Math.min(2 ** attempt, 8) * 1000 + Math.random() * 250;
console.warn(`rate_limited — retrying in ${Math.round(wait)}ms`);
await sleep(wait);
continue;
}
}
if (!response.ok || body.ok !== true) {
throw new MelodreamsError(
body.error ?? `http_${response.status}`,
body.message ?? response.statusText,
response.status,
);
}
return body.data;
}
}
try {
const me = await melodreams('/v1/me');
console.log(`Token belongs to @${me.username} (tier: ${me.tier})`);
const profile = await melodreams('/v1/profiles/aurora');
console.log(`${profile.display_name} — ${profile.links.length} links`);
} catch (error) {
if (error instanceof MelodreamsError && error.code === 'plan_required') {
console.error('This account has no active Melodreams subscription.');
} else {
console.error(String(error));
}
process.exit(1);
}
requestsimport os
import random
import sys
import time
import requests
BASE = "https://api-dev1.melodreams.com"
TOKEN = os.environ.get("MELODREAMS_API_TOKEN")
if not TOKEN:
sys.exit("MELODREAMS_API_TOKEN is not set. Refusing to run.")
class MelodreamsError(RuntimeError):
def __init__(self, code, message, status=None):
super().__init__("{}: {}".format(code, message))
self.code = code
self.status = status
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + TOKEN,
"Accept": "application/json",
"User-Agent": "my-integration/1.0",
})
def melodreams(path, retries=3):
attempt = 0
while True:
response = session.get(BASE + path, timeout=10)
try:
body = response.json()
except ValueError:
body = {}
if response.status_code == 429:
if body.get("error") in ("quota_exceeded", "distinct_profile_cap"):
raise MelodreamsError(
body["error"],
body.get("message", "not retryable"),
429,
)
if attempt < retries:
wait = min(2 ** attempt, 8) + random.uniform(0, 0.25)
print("rate_limited - retrying in {:.1f}s".format(wait), file=sys.stderr)
time.sleep(wait)
attempt += 1
continue
if not response.ok or body.get("ok") is not True:
raise MelodreamsError(
body.get("error", "http_{}".format(response.status_code)),
body.get("message", response.reason),
response.status_code,
)
return body["data"]
def main():
try:
me = melodreams("/v1/me")
print("Token belongs to @{} (tier: {})".format(me["username"], me["tier"]))
profile = melodreams("/v1/profiles/aurora")
print("{} - {} links".format(profile["display_name"], len(profile["links"])))
except MelodreamsError as error:
if error.code == "plan_required":
sys.exit("This account has no active Melodreams subscription.")
sys.exit(str(error))
if __name__ == "__main__":
main()
<?php
declare(strict_types=1);
const MELODREAMS_BASE = 'https://api-dev1.melodreams.com';
final class MelodreamsError extends RuntimeException
{
public string $code;
public function __construct(string $code, string $message, int $status = 0)
{
parent::__construct($code . ': ' . $message, $status);
$this->code = $code;
}
}
function melodreams(string $token, string $path, int $retries = 3): array
{
for ($attempt = 0; ; $attempt++) {
$curl = curl_init(MELODREAMS_BASE . $path);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Accept: application/json',
'User-Agent: my-integration/1.0',
],
]);
$raw = curl_exec($curl);
if ($raw === false) {
$transport = curl_error($curl);
curl_close($curl);
throw new MelodreamsError('transport_error', $transport);
}
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$body = json_decode((string) $raw, true);
if (!is_array($body)) {
$body = [];
}
if ($status === 429) {
$code = (string) ($body['error'] ?? '');
if ($code === 'quota_exceeded' || $code === 'distinct_profile_cap') {
throw new MelodreamsError($code, (string) ($body['message'] ?? 'not retryable'), 429);
}
if ($attempt < $retries) {
$wait = min(2 ** $attempt, 8);
fwrite(STDERR, "rate_limited - retrying in {$wait}s\n");
sleep($wait);
continue;
}
}
if ($status >= 400 || ($body['ok'] ?? false) !== true) {
throw new MelodreamsError(
(string) ($body['error'] ?? 'http_' . $status),
(string) ($body['message'] ?? 'Request failed.'),
$status
);
}
return $body['data'];
}
}
$token = getenv('MELODREAMS_API_TOKEN');
if ($token === false || $token === '') {
fwrite(STDERR, "MELODREAMS_API_TOKEN is not set. Refusing to run.\n");
exit(1);
}
try {
$me = melodreams($token, '/v1/me');
printf("Token belongs to @%s (tier: %s)\n", $me['username'], $me['tier']);
$profile = melodreams($token, '/v1/profiles/aurora');
printf("%s - %d links\n", $profile['display_name'], count($profile['links']));
} catch (MelodreamsError $error) {
if ($error->code === 'plan_required') {
fwrite(STDERR, "This account has no active Melodreams subscription.\n");
} else {
fwrite(STDERR, $error->getMessage() . "\n");
}
exit(1);
}
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"time"
)
const baseURL = "https://api-dev1.melodreams.com"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error string `json:"error"`
Message string `json:"message"`
}
type APIError struct {
Code string
Message string
Status int
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
type Client struct {
Token string
HTTP *http.Client
Retries int
}
func (c *Client) Get(path string, out interface{}) error {
for attempt := 0; ; attempt++ {
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "my-integration/1.0")
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
raw, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return err
}
var env envelope
_ = json.Unmarshal(raw, &env)
if res.StatusCode == http.StatusTooManyRequests {
if env.Error == "quota_exceeded" || env.Error == "distinct_profile_cap" {
return &APIError{
Code: env.Error,
Message: env.Message,
Status: res.StatusCode,
}
}
if attempt < c.Retries {
wait := time.Duration(math.Min(math.Pow(2, float64(attempt)), 8)) * time.Second
log.Printf("rate_limited - retrying in %s", wait)
time.Sleep(wait)
continue
}
}
if res.StatusCode >= 400 || !env.OK {
code := env.Error
if code == "" {
code = fmt.Sprintf("http_%d", res.StatusCode)
}
return &APIError{Code: code, Message: env.Message, Status: res.StatusCode}
}
return json.Unmarshal(env.Data, out)
}
}
func main() {
token := os.Getenv("MELODREAMS_API_TOKEN")
if token == "" {
log.Fatal("MELODREAMS_API_TOKEN is not set. Refusing to run.")
}
client := &Client{
Token: token,
HTTP: &http.Client{Timeout: 10 * time.Second},
Retries: 3,
}
var me struct {
Username string `json:"username"`
Tier string `json:"tier"`
}
if err := client.Get("/v1/me", &me); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Code == "plan_required" {
log.Fatal("This account has no active Melodreams subscription.")
}
log.Fatal(err)
}
fmt.Printf("Token belongs to @%s (tier: %s)\n", me.Username, me.Tier)
var profile struct {
DisplayName string `json:"display_name"`
Links []struct {
Label string `json:"label"`
URL string `json:"url"`
} `json:"links"`
}
if err := client.Get("/v1/profiles/aurora", &profile); err != nil {
log.Fatal(err)
}
fmt.Printf("%s - %d links\n", profile.DisplayName, len(profile.Links))
}
require 'json'
require 'net/http'
require 'uri'
BASE = 'https://api-dev1.melodreams.com'.freeze
TOKEN = ENV['MELODREAMS_API_TOKEN']
abort 'MELODREAMS_API_TOKEN is not set. Refusing to run.' if TOKEN.nil? || TOKEN.empty?
class MelodreamsError < StandardError
attr_reader :code, :status
def initialize(code, message, status = nil)
@code = code
@status = status
super("#{code}: #{message}")
end
end
def melodreams(path, retries: 3)
uri = URI.join(BASE, path)
attempt = 0
loop do
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{TOKEN}"
request['Accept'] = 'application/json'
request['User-Agent'] = 'my-integration/1.0'
response = Net::HTTP.start(
uri.hostname, uri.port,
use_ssl: true, open_timeout: 10, read_timeout: 10
) { |http| http.request(request) }
body = begin
JSON.parse(response.body.to_s)
rescue JSON::ParserError
{}
end
status = response.code.to_i
if status == 429
if ['quota_exceeded', 'distinct_profile_cap'].include?(body['error'])
raise MelodreamsError.new(
body['error'],
body['message'] || 'not retryable',
429
)
end
if attempt < retries
wait = [2**attempt, 8].min
warn "rate_limited - retrying in #{wait}s"
sleep wait
attempt += 1
next
end
end
unless status < 400 && body['ok'] == true
raise MelodreamsError.new(
body['error'] || "http_#{status}",
body['message'] || 'Request failed.',
status
)
end
return body['data']
end
end
begin
me = melodreams('/v1/me')
puts "Token belongs to @#{me['username']} (tier: #{me['tier']})"
profile = melodreams('/v1/profiles/aurora')
puts "#{profile['display_name']} - #{profile['links'].length} links"
rescue MelodreamsError => error
abort 'This account has no active Melodreams subscription.' if error.code == 'plan_required'
abort error.message
end
#!/usr/bin/env bash
set -euo pipefail
: "${MELODREAMS_API_TOKEN:?MELODREAMS_API_TOKEN is not set. Refusing to run.}"
BASE="https://api-dev1.melodreams.com"
melodreams() {
local path="$1"
local response status body
response="$(curl --silent --show-error \
--retry 3 --retry-delay 2 --retry-all-errors \
--write-out $'\n%{http_code}' \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
--header "Accept: application/json" \
"${BASE}${path}")"
status="$(printf '%s' "$response" | tail -n 1)"
body="$(printf '%s' "$response" | sed '$d')"
if [ "$status" -ge 400 ]; then
local code
code="$(printf '%s' "$body" | jq -r '.error // "http_error"')"
if [ "$code" = "quota_exceeded" ] || [ "$code" = "distinct_profile_cap" ]; then
printf '%s: not retryable, do not loop on this.\n' "$code" >&2
fi
printf 'Request failed (%s): %s\n' \
"$status" "$(printf '%s' "$body" | jq -r '.message // "no message"')" >&2
return 1
fi
printf '%s' "$body"
}
melodreams "/v1/me" | jq -r '"Token belongs to @" + .data.username + " (tier: " + .data.tier + ")"'
melodreams "/v1/profiles/aurora" \
| jq -r '.data | .display_name + " - " + (.links | length | tostring) + " links"'
Send a real User-Agent. It costs nothing and it
makes your integration identifiable when something goes wrong.
Set a timeout on every request. None of these clients will hang forever on a slow network. A missing timeout is how a scheduled job turns into a stuck process.
OpenAPI specification #
The full API is described by a machine-readable OpenAPI 3.1 document:
https://developer.melodreams.com/docs/openapi.json
It covers every path, parameter, response schema, error code, security scheme and rate-limit header on this page, and it is the same document the examples above were checked against.
Why there are no official SDKs #
Because a generator does the job better than we would. This is a read-only API with six endpoints and one authentication scheme — precisely the shape that OpenAPI generators handle well and precisely the shape where a hand-written SDK per language adds a release cadence, a version matrix and a lag between the API changing and the client catching up.
Point any OpenAPI generator at the spec and you get a typed client in most mainstream languages:
The first command below emits TypeScript types with no runtime dependency; the second generates a complete client in any language the generator supports.
npx openapi-typescript "https://developer.melodreams.com/docs/openapi.json" \
--output melodreams.d.ts
npx @openapitools/openapi-generator-cli generate \
--input-spec "https://developer.melodreams.com/docs/openapi.json" \
--generator-name python \
--output ./melodreams-client
If you would rather not generate anything, the code examples above are about forty lines per language and have no dependencies beyond an HTTP client. For six read-only endpoints, that is often the better trade.
Most generators produce a client with no retry logic and no distinction
between the three 429s — rate_limited, which is
retryable, and quota_exceeded and
distinct_profile_cap, which are not. Wrap the generated call in
your own layer that implements the retry policy,
or you will find yourself hammering a limit that cannot recover until
midnight or until the 1st of the month.