Melodreams Developer API
A read-only HTTP API for Melodreams profile data and your own analytics. Six GET endpoints, three scopes, one response envelope, and a fixed set of error codes you can switch on.
Introduction #
The Melodreams Developer API reads public profiles and your own account data
over HTTPS. The base URL is https://api-dev1.melodreams.com.
https://api-dev1.melodreams.com
Every endpoint is a GET and returns JSON. Every request except
/health and
/v1/scopes needs a bearer token from an
account on an active paid plan. The API is server-side only and rejects browser
origins by CORS, so hold the token on a server you control.
Create a token in the developer portal.
Authentication #
Send a bearer token in the Authorization header on every request.
This is the only transport. There is no query-parameter or cookie auth.
curl --silent \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
--header "Accept: application/json" \
"https://api-dev1.melodreams.com/v1/me"
A request with no header returns
401 missing_token and a
WWW-Authenticate: Bearer challenge. ?token= and
?api_key= do nothing: a credential in a URL leaks into access logs,
shell history, and Referer headers.
CORS allows one browser origin, the developer portal, so a fetch()
from your own site fails by design. Anything shipped to a browser or an app
binary is readable by whoever receives it. Keep the token on a server, put a
thin backend of your own in front, and expose only the data your client needs.
Plan status is checked live on every request against the account that owns the
token, not baked in when the token is created. With no active plan, every
/v1 request, including /v1/me,
returns 402 plan_required.
Resubscribing takes effect within seconds and the token itself does not change.
Token format #
A token is one opaque string of 75 characters, built from four parts. The parts are shown so you can recognise a token and tell two of your own apart. Your code should never take it apart. The example below is not a working credential.
-
mdr
Vendor prefix, constant on every Melodreams token. It makes a leaked token greppable in a repository scan and lets secret scanners match the pattern.
-
live
Environment label, always
live. There is no test environment and nomdr_test_counterpart. For development, mint a separate token with a short expiry and the narrowest scopes. -
Zk9xQ2pM7vTb1sYwLd4NrA
The public half, base64url. Shown in the portal and safe to log.
/v1/mereturns it astoken.id. -
8pQv…VwXyz
The secret half, base64url. Shown once at creation and never again. Store it in a secret manager. Nobody at Melodreams can recover it.
_.
The id and the secret are base64url, and that alphabet includes _
and -, so most tokens contain a _ inside a segment.
Splitting on _ and expecting four parts is wrong. Read the whole
string from your environment, put it in the header, and never parse, trim,
lowercase, or re-encode it. For a short log label, take the first 31 characters,
which are mdr_live_<token_id>.
Get a token #
- Sign in to a Melodreams account on an active paid plan.
- Open the developer portal. The session cookie is shared, so there is no second sign-in.
- Go to token management and create a token: give it a name (1 to 60 characters), pick its scopes, and pick an expiry.
- Copy the token immediately. The full string is shown once and is never recoverable. If you lose it, revoke it and create another.
Token management is session-authenticated only. An API token cannot create, list, or revoke tokens, including itself. Scopes and expiry are fixed at creation: to change them, mint a new token and revoke the old one. You can hold up to 5 active tokens per account.
Expiry options are 1, 7, 30, 90, or 365 days, or never. Prefer the shortest expiry
your deployment can absorb, so rotation is enforced rather than remembered. An
expired token returns
401 token_expired.
Making requests #
The cheapest call to confirm a token works is
GET /v1/me. It needs a valid token but no scope,
and reports the account, scopes, and limits the token carries. Here it is with
curl and with the SDK.
curl --silent \
--header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
"https://api-dev1.melodreams.com/v1/me"
import { Melodreams } from '@heymelodreams/sdk';
const mdr = new Melodreams(process.env.MELODREAMS_API_TOKEN);
const me = await mdr.me();
console.log(`Token for @${me.username}, tier ${me.tier}`);
Set the token in your environment first and read it from there. Never hardcode it.
export MELODREAMS_API_TOKEN="mdr_live_..."
The same first call with Python and requests:
import os
import requests
BASE = "https://api-dev1.melodreams.com"
token = os.environ["MELODREAMS_API_TOKEN"]
response = requests.get(
BASE + "/v1/me",
headers={"Authorization": "Bearer " + token, "Accept": "application/json"},
timeout=10,
)
response.raise_for_status()
me = response.json()["data"]
print("Token for @{}, tier {}".format(me["username"], me["tier"]))
Response format #
Every /v1 response uses one of two shapes. Success carries
data. An error carries a code you switch on. You never
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://api-dev1.melodreams.com/docs#insufficient_scope"
}
- ok
- boolean
trueon success,falseon error. - data
- object The endpoint payload. Present on success only.
- error
- string A stable snake_case code. Present on error only. Switch on this.
- message
- string Human-readable text for 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 link to the matching row in the error reference.
/health
The liveness endpoint is not wrapped in the envelope. It returns its fields at
the top level next to ok. It sits outside /v1 because
it is not part of the versioned contract.
- HTTPS only. Plain HTTP is upgraded, never served.
- Responses are
application/json; charset=utf-8withX-Content-Type-Options: nosniff. - Timestamps are ISO 8601 in UTC, for example
2026-07-22T09:58:12.004Z. That includesX-RateLimit-Reset, which is not a Unix epoch. - Unknown fields are dropped, not passed through. The profile shapes are a projection of internal storage, so an editor change in the dashboard is not a breaking API change.
- A known path called with a method other than
GETreturns404 not_found, not405.
Rate limits #
Two request limits apply per token, enforced independently. The burst limit is 120
requests per 60 seconds; exceeding it returns
429 rate_limited and clears within a
minute, so retry with backoff. The monthly quota is 50,000 requests per token;
exhausting it returns 429 quota_exceeded,
which does not clear by retrying.
Limits are keyed on the token id, not on your IP address, so it does not matter how
many hosts your integration runs on. The monthly quota resets at 00:00 UTC on the
1st of each calendar month, not on your billing anniversary. The reset instant is
returned in X-RateLimit-Reset.
Distinct profiles #
public:read carries a third limit: a token
may read at most 500 distinct profiles per UTC day. The 501st new username returns
429 distinct_profile_cap. The word
that matters is distinct. Re-reading a username you already fetched today does not
count again, so a widget refreshing the same 20 profiles all day touches 20 of your
500. Only breadth is capped, never depth. This limit does not clear by retrying or
by trying a different username; it resets at 00:00 UTC.
| Request | Counts? | Why |
|---|---|---|
| GET /v1/profiles/aurora | First today | A username this token has not read today. Adds one to the day's set. A username that does not exist still counts. |
| GET /v1/profiles/aurora | No | Already read today. Unlimited until 00:00 UTC. |
| GET /v1/me/profile | No | Your own data. |
| GET /v1/me/analytics | No | Your own data. |
| GET /v1/me | No | Carries no username. |
Headers #
Successful responses, and the two metered 429s
(quota_exceeded and distinct_profile_cap), carry your
remaining monthly budget, so you can watch consumption without spending a request.
| 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 this 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. |
- These headers are attached to
2xxresponses and to429 quota_exceededand429 distinct_profile_cap. They are not present on401,402,403,404, or burst429 rate_limited, which are rejected before metering runs. Read them defensively. - The quota is per token, not per account, so a development token cannot spend production's 50,000.
Errors #
Errors use the error envelope. The error
field is the contract: a fixed vocabulary of stable snake_case codes. Branch on it
and log it. The code and the HTTP status never disagree.
{
"ok": false,
"error": "plan_required",
"message": "This endpoint requires an active Melodreams subscription.",
"docs": "https://api-dev1.melodreams.com/docs#plan_required"
}
Retry policy #
- Retry
rate_limited, any5xx(internal_error,upstream_error,api_unavailable,scope_paused), and network errors, with exponential backoff and jitter and a cap on attempts. Forapi_unavailableandscope_paused, wait for theRetry-Afterwindow. - Do not retry
quota_exceeded. It clears on the 1st of the month. Alert instead. - Do not retry
distinct_profile_cap, and do not route around it with a different username. It clears at 00:00 UTC. If you hit it, the shape of the integration is what to change. See Distinct profiles. - Never retry any other
4xx. A401,402, or403needs a human: a new token, a subscription, or a different scope.
Error codes #
| 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 of 5 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. |
| 400 | invalid_scope | One or more requested scopes are not recognised. Raised when creating a token, not by the read API. Compare against GET /v1/scopes. |
| 401 | missing_token | No Authorization: Bearer header was sent. ?token= is not a supported alternative. The response carries a WWW-Authenticate: Bearer challenge. |
| 401 | malformed_token | The credential is not structurally a Melodreams token. Usually a truncated paste or a shell that did not expand your environment variable. Check the string is mdr_live_<22>_<43>, 75 characters. Because _ is legal inside the id and secret, 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 the same code, so the response cannot probe which ids exist. |
| 401 | token_revoked | This token was revoked. It will never work again. Mint a new one. |
| 401 | token_expired | The token passed the expiry 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. You will not see this from /v1. |
| 402 | plan_required | The token is valid but the owning account has no active subscription. See Authentication. Resubscribing takes effect within 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 has. Scopes are immutable. Mint a new token. |
| 403 | account_blocked | The account behind the token is not in good standing: suspended, locked, or under review. Contact support. There is nothing to fix in your code. |
| 404 | not_found | No such endpoint. Also returned for a valid path called with a method other than GET. The API does not return 405. |
| 404 | profile_not_found | No public profile exists for that username. A handle that does not exist and one 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: the per-token burst limit of 120 per minute. Back off exponentially and retry; it clears within a minute. Also returned when a source IP has exhausted the budget for failed authentication and then sends a request with no token or an unparseable one, in which case the credential is the problem, not the pace. See Rate limits. |
| 429 | quota_exceeded | The token's monthly quota is exhausted. Retrying will not help. X-RateLimit-Reset and the message both carry the reset instant. |
| 429 | distinct_profile_cap | This token has read 500 distinct profiles today and asked for a 501st. Re-reading a username it already fetched today still works; only new ones are refused. Resets at 00:00 UTC. Retrying will not help, and neither will a different username. See Distinct profiles. |
| 500 | internal_error | Something went wrong on our side. No stack traces are included. 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. |
| 503 | api_unavailable | We paused the API on purpose, for maintenance or during an incident. The response carries a Retry-After header and usually a short note. Retry after the window. Nothing is wrong with your token. |
| 503 | scope_paused | One scope is switched off temporarily, usually public:read during a scraping incident. Your other scopes keep working. Retry after the Retry-After window. |
Scopes #
Scopes are chosen when a token is created and are fixed for its lifetime. A token
that lacks a required scope gets
403 insufficient_scope, and the
message names the scope required and the scopes the token has. Scopes
are additive and least privilege: grant the narrowest set that works, and mint a
new token to widen access.
Every scope in v1 is a read. There is no write scope and no webhook scope, because no code path in the API mutates or exports anything. Scope strings never change meaning: a widened capability ships as a new 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 live catalogue is served by GET /v1/scopes.
The paid-feature column marks scopes gated by plan in the product: analytics is not
available to free accounts anywhere. That is separate from the plan requirement,
which applies to the whole API regardless of scope.
Endpoints #
Six endpoints. All GET. All read-only.
| Endpoint | Auth | Scope |
|---|---|---|
| GET /health | None | None |
| GET /v1/scopes | None | 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 |
GET /health #
Unauthenticated liveness check. Use it for uptime monitoring and to confirm
reachability from a new network before you debug credentials. It costs no quota.
GET /v1/health is an alias. This is the one endpoint 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",
"ready": true,
"mode": "ok",
"cache": { "entries": 412, "max": 5000 },
"timestamp": "2026-07-22T10:31:04.118Z"
}
GET /v1/scopes #
The machine-readable scope catalogue, exactly as the portal and this page render it. Public and unauthenticated because it is not sensitive, and useful for 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
}
]
}
}
GET /v1/me #
Who the token belongs to, what it can do, and what its limits are. Any valid token may call it, whatever its scopes. Call it at start-up to verify credentials and reach for it first when something is not working.
Response fields
- user_id
- string (uuid) The 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, for example
fullorpast_due_grace. - subscribed
- boolean True while the plan requirement is satisfied.
- token.id
- string The public token id, segment 3 of the token. Safe to log.
- token.environment
- string Always
live. - token.scopes
- string[] Sorted and 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
}
}
}
GET /v1/profiles/:username #
Any public Melodreams profile by handle. The response is a stable projection of the profile: fields the API does not document are dropped, so a field you see here will still be here next month.
Path parameters
- username
-
string, required
Case-insensitive and 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, so the
endpoint cannot be used to enumerate private handles. This is the only endpoint
subject to the distinct-profile cap. Responses
are cacheable: they carry Cache-Control: public, max-age=60, so an
edit can take up to a minute to appear. Honour the header to save quota.
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
title,url,style(bigorsmall), andtype(defaults tostandard). Links with no destination and hidden links are omitted. - badges
- object
verified,staff,developer, each a boolean. - flags
- object
access_code_required,status_dot,discoverable,listable. A gated profile is reported as a boolean only; the access-code hash is never read from storage. - 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
{
"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": [
{
"title": "Latest release",
"url": "https://open.spotify.com/album/4kaurora",
"style": "big",
"type": "music"
},
{
"title": "Tour dates",
"url": "https://aurora.example/tour",
"style": "small",
"type": "standard"
}
],
"badges": {
"verified": true,
"staff": false,
"developer": false
},
"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"
}
}
GET /v1/me/profile #
The token owner's own profile. Identical in shape to
the public projection, with one extra
visibility object. Works whether or not the profile is currently
public. This response is specific to one token and is sent with
Cache-Control: no-store.
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.
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": [
{
"title": "Latest release",
"url": "https://open.spotify.com/album/4kaurora",
"style": "big",
"type": "music"
}
],
"badges": {
"verified": true,
"staff": false,
"developer": false
},
"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"
}
}
}
GET /v1/me/analytics #
The token owner's profile view count and link click counters. No query parameters, no date ranges, no filters: it returns the current counters, the same data the dashboard shows. Analytics is a paid capability, so this endpoint re-checks the live plan on every request in addition to the scope check. A 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 Each entry has
link_id,title,url,style, andcount, sorted bycountdescending and capped at the top 100 links. - updated_at
- string (ISO 8601) | null When the counters were last written.
Example request
curl --silent \
--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": [
{
"link_id": "lnk_9f2a",
"title": "Latest release",
"url": "https://open.spotify.com/album/4kaurora",
"style": "big",
"count": 2871
},
{
"link_id": "lnk_3c1b",
"title": "Tour dates",
"url": "https://aurora.example/tour",
"style": "small",
"count": 1102
}
]
},
"updated_at": "2026-07-22T09:58:12.004Z"
}
}
Versioning #
The version is in the path. Everything under /v1 keeps its shape. A
breaking change ships as a new path, /v2; /v1 is not
altered in place.
These changes are additive and can ship without notice, so parse defensively and ignore fields you do not recognise:
- New endpoints, new response fields, and new optional query parameters.
- New scopes. An existing token does not gain them; mint a new token to get them.
These are breaking and ship as /v2:
- Removing or renaming a field, narrowing a type, or changing the meaning of an existing value.
- Adding a required parameter or tightening a limit.
Error codes are frozen. A shipped error value never changes meaning;
only its message is reworded. Tokens are opaque, so never parse a token
to infer a version or anything else.
SDKs and OpenAPI #
The official SDK is TypeScript, has zero dependencies, and is server-side only.
npm i @heymelodreams/sdk
Construct a client with one token. Each method mirrors one endpoint and returns the
data payload. Any non-ok response throws a typed
MelodreamsError with code and status, so you
apply the retry policy by branching on
err.code.
import { Melodreams, MelodreamsError } from '@heymelodreams/sdk';
const mdr = new Melodreams(process.env.MELODREAMS_API_TOKEN);
try {
const me = await mdr.me();
console.log(`Token for @${me.username}, tier ${me.tier}`);
const profile = await mdr.profile('aurora');
console.log(`${profile.display_name}: ${profile.links.length} links`);
const stats = await mdr.myAnalytics();
console.log(`${stats.views} views, ${stats.clicks.total} clicks`);
} catch (err) {
if (err instanceof MelodreamsError) {
console.error(`${err.code} (${err.status})`);
process.exit(1);
}
throw err;
}
OpenAPI spec #
The full API is described by an OpenAPI 3.1 document, served from this documentation site. It covers every path, parameter, response schema, error code, and rate-limit header on this page.
/docs/openapi.json
Save the file, then point any generator at it for a typed client. The first command below emits TypeScript types with no runtime dependency; the second generates a full client in most mainstream languages.
npx openapi-typescript ./openapi.json --output melodreams.d.ts
npx @openapitools/openapi-generator-cli generate \
--input-spec ./openapi.json \
--generator-name python \
--output ./melodreams-client
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 a layer that follows
the retry policy, or you will hammer a limit that
cannot recover until midnight or the 1st of the month.