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"
import { Northwest } from '@northwestfalls/sdk';
const nw = new Northwest(process.env.NORTHWEST_API_KEY);
const me = await nw.whoami();
console.log(me.account.plan, me.key.scopes);
const res = await fetch('https://api-dev1.northwestfalls.com/v1/whoami', {
headers: { Authorization: `Bearer ${process.env.NORTHWEST_API_KEY}` },
});
const me = await res.json();
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.
| Scope | Lets a key |
|---|---|
catalog:read | Read tracks, releases and versions |
links:read | Read share links and their recipients |
links:write | Create, edit and revoke share links |
analytics:read | Read 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.
| Plan | Per minute | Per calendar month |
|---|---|---|
| Studio | 120 | 50,000 |
| Test keys | 120 | 5,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.
/whoami
no scope
Who the key belongs to. The cheapest way to check a key works.
/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"
}
]
}
/tracks/:id
catalog:read
One track, with every version.
/releases
catalog:read
Every release, with track counts and dates.
/links
links:read
Every share link you hold, with its counters.
/links
links:write
Make a share link for a track or a release. Sharing the same target twice hands back the address you already have rather than minting a second one.
Request and response
{
"target_type": "track",
"target_id": "xnxfargpdbzq",
"quality": "standard",
"expires_in_days": 30
}
{
"ok": true,
"link": {
"slug": "quiet-hours",
"url": "https://northlink.cc/t/quiet-hours",
"target_type": "track",
"quality": "standard",
"allow_download": false,
"has_passcode": false,
"expires_at": "2026-09-05T00:00:00Z"
}
}
/links/:slug
links:write
Change a link's settings: expiry, passcode, download, quality.
/links/:slug
links:write
Revoke it. Playback stops at once and the address stays spent.
/links/:slug/stats
analytics:read
Opens, plays and downloads, with a daily series.
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"
}
| Slug | Status | Means |
|---|---|---|
missing_token | 401 | No Authorization header |
invalid_token | 401 | Not a key we know |
revoked_token | 401 | Someone turned it off |
insufficient_scope | 403 | Key lacks the scope, named in required_scope |
api_not_on_plan | 403 | The plan does not include API access |
session_only | 403 | Do this in the app, not with a key |
browser_request | 403 | Sent from a browser. Call from your server |
rate_limited | 429 | Too fast. See Retry-After |
quota_exceeded | 429 | Out of calls for the month |
server_error | 500 | Ours. 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.