Melodreams Melodreams Melodreams Developer API api-dev1.melodreams.com
Version 1 · Read-only

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_found so 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:read returns strictly what an anonymous browser already sees at melo.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.
Missing something?

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 #

Base URL
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.

Success
{
  "ok": true,
  "data": { }
}
Error
{
  "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.
One exception: /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 includes X-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 get invalid_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, not 405. The API exposes only GET and does not advertise anything else.
  • Versioning is in the path. A breaking change ships as /v2; /v1 keeps 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 /v2 launches, /v1 keeps 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 Deprecation and Sunset (both HTTP-date, per RFC 8594) plus a Link header with rel="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 error value never changes meaning. Only message gets 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_live_Zk9xQ2pM7vTb1sYwLd4NrA_8pQvK2mN4rT7wXyZ0aBcDeFgHiJkLmNoPqRsTuVwXyz
  1. mdrsegment 1 · fixed · 3 chars

    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.

  2. livesegment 2 · always live · 4 chars

    Environment label, and currently a constant: every Melodreams token is a live token. There is no sandbox environment — see There is no sandbox for why, and for what to do during development instead.

  3. Zk9xQ2pM7vTb1sYwLd4NrAsegment 3 · token id · 22 chars

    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_Zk9xQ2pM7vTb1sYwLd4NrA so you can tell two tokens apart.

  4. 8pQv…VwXyzsegment 4 · secret · 43 chars

    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.

Treat the token as opaque. Do not split it on _.

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 #

  1. Sign in at melodreams.com with an account on an active paid plan — see Plan requirement.
  2. Open the developer portal. The session cookie is shared across melodreams.com, so there is no second sign-in.
  3. 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.
  4. 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.

What to do instead, for local work, CI and staging

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
curl --silent --show-error \
     --header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
     --header "Accept: application/json" \
     "https://api-dev1.melodreams.com/v1/me"
Query-parameter auth is not supported

?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.

Server-side only. A token in front-end code is a leaked token.

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 .env to .gitignore before 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 Authorization header, 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:read and nothing else. Adding analytics: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_at in 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 #

ItemHow 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.
We cannot recover your secret. Nobody can.

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 #

The Developer API requires an active Melodreams subscription. There is no free tier.

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 #

120requests / minute, per token
50,000requests / month, per token
500distinct profiles / day, per token
5active tokens per account

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.

Failed authentication is throttled separately, per IP

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 #

500distinct profiles / UTC day, per token
re-reads of a profile already seen today
0counted for reads of your own data

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.

RequestCounts?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.

If you have a legitimate need for more, ask

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 message reports how many distinct profiles the token has read today and what the cap is, as context. Like every message, it is for humans and logs — never parse it. Branch on error === "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_found has 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.

HeaderExampleMeaning
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 2xx responses and to 429 quota_exceeded and 429 distinct_profile_cap. They are not present on 401, 402, 403, 404 or burst 429 rate_limited responses, 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-Reset are omitted rather than guessed. Authentication fails closed; metering fails open. The asymmetry is intentional — an infrastructure problem on our side should not become a false 429 storm 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_request telling 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.

ScopeGrantsReadsPaid 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.

EndpointAuthScope
GET /healthNone
GET /v1/scopesNone
GET /v1/meTokenAny
GET /v1/profiles/{username}Tokenpublic:read
GET /v1/me/profileTokenprofile:read
GET /v1/me/analyticsTokenanalytics:read

Health #

GET /health No authentication

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
curl --silent "https://api-dev1.melodreams.com/health"

Example response 200

JSON
{
  "ok": true,
  "service": "developer-api",
  "version": "v1",
  "cache": {
    "entries": 412,
    "max": 5000
  },
  "timestamp": "2026-07-22T10:31:04.118Z"
}

Scope catalogue #

GET /v1/scopes No authentication

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
curl --silent "https://api-dev1.melodreams.com/v1/scopes"

Example response 200

JSON
{
  "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 #

GET /v1/me Valid token, no scope required

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 user or org.
tier
string The live access tier: full or past_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_minute and requests_per_month for this token.

Example request

cURL
curl --silent \
     --header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
     "https://api-dev1.melodreams.com/v1/me"

Example response 200

JSON
{
  "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 #

GET /v1/profiles/{username} public:read

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, otherwise 400 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 label and url, plus optional icon, category and thumbnail. Links with no destination are omitted. Editor internals — ordering keys, ids and scheduling windows — are never included.
socials[]
array Each entry has platform and value.
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
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

JSON
{
  "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 #

GET /v1/me/profile profile:read

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
curl --silent \
     --header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
     "https://api-dev1.melodreams.com/v1/me/profile"

Example response 200 — abridged

JSON
{
  "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 #

GET /v1/me/analytics analytics:read Paid feature

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 by count descending 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
curl --silent --dump-header - \
     --header "Authorization: Bearer ${MELODREAMS_API_TOKEN}" \
     "https://api-dev1.melodreams.com/v1/me/analytics"

Example response 200

JSON
{
  "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 #

EndpointCache-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 #

Revocation is not instantaneous. It completes within 30 seconds.

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.

StatusCodeWhat 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_error and upstream_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_cap either, 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 4xx other than 429. A 401, 402 or 403 means 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.

Set the token in your environment first
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.

melodreams.mjs — Node 18+, no dependencies
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);
}
Two things worth copying from these examples

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:

Specification
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.

Generate a client
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.

Generated clients and retries

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.