The API
Keep your own site, your own intake form and your own release tooling. This is everything the app can do, available to your server.
Start here
Create a key in the app under Settings, then API keys. Keys start with
nwf_live_ or nwf_test_.
curl https://api-dev1.northwestfalls.com/v1/whoami \
-H "Authorization: Bearer $NORTHWEST_TOKEN"
Or with the client, which has no dependencies:
npm install @northwestfalls/sdk
import { NorthwestFalls } from '@northwestfalls/sdk';
const nw = new NorthwestFalls({ token: process.env.NORTHWEST_TOKEN });
const { tracks } = await nw.tracks({ limit: 20 });
for (const track of tracks) console.log(track.title, track.duration);
Server side only. A key in a browser is readable by
everyone who loads the page. Any request that arrives with an
Origin header is refused, and the 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>. A key
only does what its scopes allow, so give each integration the narrowest set
that works.
| Scope | Lets a key |
|---|---|
catalog:read | Read tracks, releases and versions |
catalog:write | Create and edit releases |
uploads:write | Upload masters and artwork |
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 |
labels:read | Read roster and members |
submissions:read | Read demo submissions |
webhooks:read | Read endpoints and their deliveries |
Revoking a key takes effect within about thirty seconds. Creating and removing keys and webhook endpoints is done 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 |
| Label | 120 | 250,000 |
| Label Pro | 120 | 1,000,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.
| Call | Scope | Does |
|---|---|---|
GET /whoami | none | Who the key belongs to |
GET /tracks | catalog:read | Paged, with cursor |
GET /tracks/:id | catalog:read | One track and its versions |
GET /releases | catalog:read | Every release |
POST /releases | catalog:write | Create a release |
GET /links | links:read | Every share link |
POST /links | links:write | Create one |
PATCH /links/:slug | links:write | Change its settings |
DELETE /links/:slug | links:write | Revoke it |
GET /links/:slug/stats | analytics:read | Opens, plays, downloads |
GET /links/:slug/recipients | links:read | Who it went to |
POST /links/:slug/recipients | links:write | Give each person their own address |
POST /trace | analytics:read | Turn a code into a person |
GET /labels/:id/roster | labels:read | Artists on the roster |
GET /labels/:id/submissions | submissions:read | Demos received |
GET /imports | catalog:read | Connected storage |
POST /imports/:id/scan | catalog:write | Look for new audio |
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.
Marked copies, and finding a leak
Give each person their own address and their own copy of the audio, carrying a code only they have.
const { link } = await nw.createLink({ target_type: 'track', target_id: trackId });
await nw.addRecipients(link.slug, ['Anna at XL', 'Ben at 4AD']);
await nw.enableWatermark(link.slug);
const { recipients } = await nw.recipients(link.slug);
for (const person of recipients) console.log(person.name, person.url);
When a file turns up somewhere it should not:
const hit = await nw.trace('a1b2c3d4e5f6');
console.log(hit.recipient.name, hit.activity.plays);
You can also upload the file itself in the app and it will recover the code for you. The mark is a quiet tone under the music and survives being re-encoded.
A code says whose copy a file came from. It does not say who shared it. Treat it as the start of a conversation, not proof of anything.
Webhooks
Add an endpoint in the app under Settings, then Webhooks. We call it when something happens instead of you asking on a timer.
| Event | Fires when |
|---|---|
transcode.completed | A track is ready to play |
watermark.ready | A marked copy is ready for a recipient |
link.created | A share link is created |
link.viewed | Someone opens a share link |
link.revoked | A share link is revoked |
submission.received | A demo arrives through your portal |
Check the signature before you trust the body
Every call carries X-NW-Signature: t=<unix>,v1=<hash>.
Sign the timestamp, a full stop, then the raw body you
received. Not a re-serialised object.
import { verifyWebhook } from '@northwestfalls/sdk';
app.post('/hooks/northwest', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
const good = await verifyWebhook(
process.env.NORTHWEST_WEBHOOK_SECRET,
req.header('X-NW-Signature'),
raw
);
if (!good) return res.sendStatus(400);
res.sendStatus(200);
await handle(JSON.parse(raw));
});
Answer with any 2xx as soon as you have stored the event, then do the slow work. We retry eight times over about a day: 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, then 24. Twenty failures in a row and we turn the endpoint off and tell you.
Deliveries can arrive more than once and can arrive out of order. Use the
id field to make your handler idempotent, and do not infer
sequence from arrival.
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>
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.
If you would rather draw it yourself, the same data is JSON at
https://northlink.cc/api/embed/<slug>: title, artist,
artwork, duration, a stream URL and a waveform URL.
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 { "ok": false, "error": "<slug>" } with
a human message beside it. The slug is part of the contract
and will not be renamed. The message may be reworded at any time, so branch
on the slug.
| 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.
What we promise about versioning, deprecation and limits is in the policy. Anything unclear: the contact form.