Northwest Falls

The API

Read your catalogue and manage share links from your own tooling. One key, four scopes, nine endpoints.

Start here

Make a key in the app under Developers, then ask who it belongs to. If this returns your account, everything else on this page will work.

curl https://api-dev1.northwestfalls.com/v1/whoami \
  -H "Authorization: Bearer $NORTHWEST_API_KEY"

Which answers:

{
  "ok": true,
  "key": { "id": "…", "environment": "live", "scopes": ["catalog:read"] },
  "account": { "id": "…", "plan": "studio", "status": "active" }
}

Server side only. A key in a browser is a key you have published. Any request arriving with an Origin header is refused, and the Node client throws if it detects a browser. To put a player on your own site use the embed, which needs no key at all.

Keys and scopes

Every request carries Authorization: Bearer <key>. Keys start with nwf_live_ or nwf_test_. A key does only what its scopes allow, so give each integration the narrowest set that works.

ScopeLets a key
catalog:readRead tracks, releases and versions
links:readRead share links and their recipients
links:writeCreate, edit and revoke share links
analytics:readRead link statistics, and trace a marked copy

Revoking a key takes effect within about thirty seconds. Making and removing keys happens in the app, never with a key: a leaked key must not be able to grant itself a successor.

Test keys

A nwf_test_ key has a smaller monthly allowance. It currently reaches the same catalogue as a live key, so treat anything you do with it as real.

Limits

Numbers, not judgement calls.

PlanPer minutePer calendar month
Studio12050,000
Test keys1205,000

Every response carries X-Quota-Limit, X-Quota-Used and X-Quota-Reset. A 429 carries Retry-After and means wait, not stop. The month is the UTC calendar month.

Endpoints

All under https://api-dev1.northwestfalls.com/v1. Open any one for a real response.

GET /whoami no scope

Who the key belongs to. The cheapest way to check a key works.

GET /tracks catalog:read

Your catalogue, newest first. Takes limit, cursor, q and release.

Response
{
  "ok": true,
  "cursor": "eyJpZCI6…",
  "tracks": [
    {
      "id": "xnxfargpdbzq",
      "title": "nostalgia.exe",
      "duration": 153,
      "bpm": 92,
      "key": "F# minor",
      "version_count": 3,
      "artwork_url": "https://img.northwestfalls.com/…/card.jpg",
      "release": { "id": "rl_8fk2", "title": "Half Light", "type": "ep" },
      "created_at": "2026-07-14T09:12:44Z"
    }
  ]
}
GET /tracks/:id catalog:read

One track, with every version.

GET /releases catalog:read

Every release, with track counts and dates.

Responses only ever contain the fields documented here. We add fields without warning and never remove or rename one inside v1, so write code that ignores keys it does not recognise.

Paging

/tracks answers with a cursor. Pass it back to get the next page, and stop when it comes back null.

for await (const track of nw.eachTrack()) {
  console.log(track.title);
}

Embedding a player

No key, and safe in a browser. One script tag and an element:

<script type="module" src="https://assets.northwestfalls.com/assets/js/embed.js"></script>
<northwest-player link="your-link-slug"></northwest-player>

Which gives you this:

It renders in a shadow root, so your CSS cannot collide with ours and ours cannot leak into your page. Style it with custom properties (--nwp-bg, --nwp-fg, --nwp-accent, --nwp-radius) or reach individual pieces with ::part(play), ::part(title), ::part(waveform). It emits nwp:play, nwp:ended and nwp:error.

<northwest-player
  link="your-link-slug"
  theme="light"
  style="--nwp-accent: #c98f5a; --nwp-radius: 20px"></northwest-player>

Drawing it yourself

The same data is JSON, no key needed, and it is what the component reads:

curl https://northlink.cc/api/embed/your-link-slug
{
  "ok": true,
  "title": "nostalgia.exe",
  "artist": "Your artist name",
  "artwork_url": "https://northlink.cc/asset/…/full.jpg",
  "duration": 153,
  "stream_url": "https://northlink.cc/stream/your-link-slug",
  "peaks_url": "https://northlink.cc/peaks/your-link-slug",
  "state": "ready"
}

A link behind a passcode or an email gate cannot be embedded. Both surfaces answer with the address to send the listener to instead.

Errors

Every failure is the same shape. The slug is part of the contract and will not be renamed. The message beside it may be reworded at any time, so branch on the slug.

{
  "ok": false,
  "error": "insufficient_scope",
  "message": "This key does not have the links:write scope.",
  "required_scope": "links:write"
}
SlugStatusMeans
missing_token401No Authorization header
invalid_token401Not a key we know
revoked_token401Someone turned it off
insufficient_scope403Key lacks the scope, named in required_scope
api_not_on_plan403The plan does not include API access
session_only403Do this in the app, not with a key
browser_request403Sent from a browser. Call from your server
rate_limited429Too fast. See Retry-After
quota_exceeded429Out of calls for the month
server_error500Ours. Quote the ref

A 500 carries a random ref. Quote it and we can find the exact request.

Retrying

Retry a 429 and a 5xx, honouring Retry-After. Do not retry a 4xx: a failed write should not be repeated behind your back. The Node client already does exactly this.

What we promise about versioning, deprecation and limits is in the policy. Anything unclear: the contact form.