Developer guide

Northwind documentation

Everything you need to send your first event, define your first signal, and deliver it somewhere useful. Written for engineers, readable by everyone else.

Introduction

Northwind turns raw product activity into decisions your revenue team can act on. You send us events and account traits; we resolve them into accounts, evaluate them against the signals you define, and deliver the result to Slack, your CRM or your warehouse.

This guide takes about twenty minutes end to end. By the end of it you will have events flowing, one signal firing, and a webhook receiving it.

Prefer not to instrument? If your events already sit in Snowflake, BigQuery, Redshift or Postgres, skip straight to Warehouse sync. No SDK required.

Quickstart

Three steps: install a server SDK, identify an account, and send an event. You need an API key from Settings → Developers. Keys are workspace-scoped and prefixed nw_live_ or nw_test_.

1. Install the SDK

bash
# npm
npm install @northwind/node

# or with pnpm
pnpm add @northwind/node

2. Initialise the client

Set region to match your workspace residency. A key issued in eu-1 will be rejected by us-1, which is deliberate.

server.js
import { Northwind } from '@northwind/node';

const nw = new Northwind({
  apiKey: process.env.NORTHWIND_API_KEY,
  // eu-1 or us-1 — must match your workspace residency
  region: 'eu-1'
});

3. Send your first event

track.js
await nw.events.track({
  accountId: 'acct_harbourline',
  userId:    'usr_8812',
  event:     'report_exported',
  timestamp: new Date().toISOString(),
  properties: {
    format:   'csv',
    rowCount: 18422,
    surface:  'dashboard'
  }
});

Within a few seconds the event appears on the account timeline in the app. If it does not, check Settings → Developers → Event log, which shows every request we received including the ones we rejected and why.

Never call the events API from a browser with a live key. Use the JavaScript SDK with a publishable key, or proxy through your own backend. Live keys grant read access to every account in the workspace.

Core concepts

Four objects make up the entire data model. Everything else is built from them.

ObjectWhat it isIdentified by
AccountA customer company. The unit of billing, health and every signal.account_id
UserA person who belongs to one or more accounts.user_id
EventSomething that happened, with a timestamp and properties.event + timestamp
SignalA named condition over accounts, users and events.signal_id

Accounts are the centre of gravity. A user without an account is stored but invisible to signals, so identify the account first — ideally at signup, before any events are sent.

Identifying accounts

Call identify whenever an account or user trait changes. It is an upsert: the first call creates the record, later calls merge traits. Sending the same payload twice is free and harmless.

identify.js
await nw.accounts.identify({
  accountId: 'acct_harbourline',
  traits: {
    name:      'Harbourline',
    plan:      'growth',
    seats:     48,
    mrr:       4800,
    industry:  'logistics',
    createdAt: '2024-02-11T09:14:00Z'
  }
});

await nw.users.identify({
  userId:    'usr_8812',
  accountId: 'acct_harbourline',
  traits: { email: 'sara@harbourline.io', role: 'admin' }
});

Traits accept strings, numbers, booleans and ISO 8601 timestamps. Nested objects are flattened to one level with dot notation. Reserved trait names — name, plan, mrr, seats, createdAt — power the built-in signal library, so use them where they fit.

Identity resolution merges users across domains, SSO providers and shared inboxes automatically. If two records should not have merged, you can split them from the account settings screen and we will remember the decision.

Building signals

A signal is a named set of conditions plus a delivery target. You can build one in the visual editor or POST it to the API — both produce the same object, and the UI will happily show you a signal created over the API.

signal.json
{
  "name": "Champion went quiet",
  "severity": "high",
  "evaluation": "streaming",
  "conditions": [
    {
      "subject": "user.role",
      "operator": "equals",
      "value": "admin"
    },
    {
      "subject": "user.last_seen_at",
      "operator": "older_than",
      "value": "14d"
    },
    {
      "subject": "account.mrr",
      "operator": "greater_than",
      "value": 1000
    }
  ],
  "deliver_to": ["slack:#cs-signals", "salesforce:task"]
}

Every visual signal compiles to SQL, which you can read at any time. This is deliberate: your data team should be able to audit a definition rather than rebuild it.

SQL
-- the compiled form of the signal above
SELECT a.account_id
FROM   accounts a
JOIN   users u ON u.account_id = a.account_id
WHERE  u.role = 'admin'
  AND  u.last_seen_at < now() - INTERVAL '14 days'
  AND  a.mrr > 1000
GROUP BY a.account_id;

Backtesting before you go live

Call POST /v1/signals/:id/backtest to replay a signal against the last twelve months. The response returns firing counts per week and a sample of matched accounts, which is usually enough to tell whether the threshold is sensible before anyone is paged.

Segments & scoring

Segments are live queries over accounts. Membership updates within seconds of new events, so a segment is never stale in the way an exported list is.

  • Filter segments return every account matching a condition set.
  • Scored segments additionally rank members by a composite score built from adoption depth, feature breadth, support sentiment and billing health.
  • Comparison puts two segments side by side to test whether a change actually moved retention.

Every segment is addressable by the API and by reverse ETL, so the definition your CSMs work from is the same one your analysts query.

Playbooks

A playbook takes a firing signal and produces an action: a Slack message, a CRM task, a drafted email, or a webhook to something you built. Steps run in order, and any step can be gated behind human approval.

  • Approval steps pause the run and notify a reviewer with the draft and full account context.
  • Throttles cap how often an account or a recipient can be touched.
  • Quiet hours respect each recipient’s timezone, not the workspace’s.
  • Outcomes are recorded against the run so you can attribute saved ARR to a specific play.

Warehouse sync

Point Northwind at your warehouse instead of instrumenting from scratch. We read on the schedule you set, map your tables to our objects, and optionally write scores and signal history back out.

WarehouseReadReverse ETLAuth
SnowflakeYesYesKey pair or password
BigQueryYesYesService account
RedshiftYesYesIAM or password
PostgresYesYesPassword over TLS

Sync definitions live in a YAML file you can keep in version control, so a schema change is a pull request rather than a support ticket.

Events API

The REST API accepts single events or batches of up to 500. Everything is idempotent on event_id; retrying a request that timed out is always safe.

HTTP
POST /v1/events/batch HTTP/1.1
Host: api.northwind.com
Authorization: Bearer nw_live_••••••••
Content-Type: application/json

{
  "events": [
    {
      "account_id": "acct_harbourline",
      "event": "report_exported",
      "timestamp": "2026-08-14T10:22:04Z"
    },
    {
      "account_id": "acct_vantive",
      "event": "seat_added",
      "timestamp": "2026-08-14T10:22:09Z"
    }
  ]
}
FieldTypeRequiredNotes
account_idstringYes**Required unless user_id is supplied and already mapped.
user_idstringNoAttributes the event to a person.
eventstringYesSnake case recommended. Max 64 characters.
timestampISO 8601NoDefaults to receipt time. Backdating up to 2 years accepted.
propertiesobjectNoMax 64 keys, one level of nesting.

Webhooks

Subscribe to signal.fired, signal.resolved, playbook.completed and account.score_changed. Payloads are JSON and delivery is at-least-once, so make your handler idempotent on the event id.

webhook payload
{
  "id": "evt_01J9Z4T7C2QK",
  "type": "signal.fired",
  "created_at": "2026-08-14T10:24:11Z",
  "data": {
    "signal": { "id": "sig_2f81", "name": "Champion went quiet" },
    "account": { "id": "acct_vantive", "name": "Vantive Group" },
    "severity": "high",
    "explanation": {
      "triggering_events": ["usr_5511:last_seen_at"],
      "threshold": "14d",
      "observed": "21d"
    }
  }
}

Verifying signatures

Every delivery carries an Nw-Signature header of the form t=1755168251,v1=<hex>. Compute an HMAC-SHA256 of {timestamp}.{raw_body} with your endpoint secret and compare in constant time. Reject anything older than five minutes.

verify.js
import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const [ts, sig] = header.split(',').map(p => p.split('=')[1]);
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Failed deliveries are retried with exponential backoff for 24 hours. After that the endpoint is disabled and the workspace owner is emailed.

Errors

Errors return a machine-readable code, a human-readable message, a link to the relevant documentation, and a request_id that our support team can look up directly.

HTTP 422
{
  "error": {
    "type": "validation_error",
    "code": "missing_account_id",
    "message": "Every event must include account_id or user_id.",
    "doc_url": "https://northwind.example.com/docs.html#errors",
    "request_id": "req_01J9Z4T7C2QK"
  }
}
StatusTypeWhat to do
400malformed_requestFix the payload. Not retryable.
401authentication_errorCheck the key and the region.
403permission_errorThe key lacks the scope. Not retryable.
422validation_errorA field failed validation. Not retryable.
429rate_limit_errorBack off using Retry-After.
5xxapi_errorRetry with exponential backoff and jitter.

Rate limits

Limits are per workspace, not per key, and are returned on every response as Nw-RateLimit-Remaining and Nw-RateLimit-Reset.

Endpoint groupStarterGrowthScale
Event ingestion100 / sec1,000 / sec10,000 / sec
Reads20 / sec100 / sec500 / sec
Backtests2 / hour20 / hour100 / hour

Batching is the cheapest path to throughput: a batch of 500 events counts as one request against the ingestion limit.

Versioning

The API is versioned by date. Your workspace is pinned to the version current when it was created, and you upgrade explicitly by sending an Nw-Version header.

  • Additive changes — new fields, new endpoints — ship without a version bump. Write clients that ignore unknown fields.
  • Breaking changes get a new dated version and are announced on the changelog at least six months before the old one is retired.
  • Deprecated fields continue to return values for the full notice period and are flagged with a Nw-Deprecation response header.

Official SDKs

All SDKs are MIT licensed, developed in the open, and follow the same release cadence as the API.

LanguagePackageUse for
Node.js@northwind/nodeServer-side ingestion, playbook webhooks
Browser@northwind/jsClient-side events with a publishable key
PythonnorthwindBatch jobs, data pipelines, notebooks
Gogo.northwind.dev/nwHigh-throughput ingestion services
Rubynorthwind-rubyRails applications

Something missing? The REST API is complete and documented — every SDK is a thin wrapper over it. If you build a client for another language, tell us and we will link it here.