npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@throughlinehq/server

v0.1.3

Published

Record Throughline Events and manage Contacts from a JavaScript or TypeScript backend.

Readme

@throughlinehq/server

Record Throughline Events and manage Contacts from a JavaScript or TypeScript backend.

npm install @throughlinehq/server
import { ThroughlineClient } from '@throughlinehq/server';

const throughline = new ThroughlineClient({
  clientId: process.env.THROUGHLINE_CLIENT_ID!,
  secret: process.env.THROUGHLINE_CLIENT_SECRET!,
});

await throughline.contacts.upsert({ externalId: 'user-42', email: '[email protected]' });
await throughline.events.track({
  eventName: 'trial_started',
  contactExternalId: 'user-42',
  properties: { plan: 'pro' },
});

Create the ClientID and secret under Settings → API keys. The ClientID is not confidential; the secret is shown once, at creation, and the client exchanges the pair for a 15-minute access token it refreshes on its own.

This package is for your backend only. It holds a secret that grants full access to your tenant's data — never ship it to a browser. Recording Events from a browser is what @throughlinehq/browser is for.

Set up a separate Tenant for staging and CI — this is not optional

There is no test mode. Every Event this package records is a real Event in the Tenant the credential belongs to, and Throughline acts on real Events: an Event can enrol a Contact in a live Journey, which sends a real email or SMS to a real person. A test suite that fires trial_started for a seeded Contact whose email address happens to be a real customer's will email that customer. There is no undo.

So: create a second Tenant — "Acme (staging)" — with its own ApiKey, and point staging, CI and every developer machine at it. Only production uses the production Tenant's credential.

# .env.production
THROUGHLINE_CLIENT_ID=tl_client_…     # production Tenant
THROUGHLINE_CLIENT_SECRET=tl_secret_…

# .env.test, .env.ci, .env.development
THROUGHLINE_CLIENT_ID=tl_client_…     # staging Tenant — a different Tenant, not a different mode
THROUGHLINE_CLIENT_SECRET=tl_secret_…

The keys look identical because they are identical in kind. What separates them is the Tenant behind them, and nothing in the key's shape will warn you when you get it wrong — only the Contacts and the Journeys will.

Contacts

Every Event is about someone, so a Contact has to exist before you can track an Event for them. upsert is the call that guarantees it, and it is safe to run on every sign-up, every profile change, and every retry.

| Call | What it does | | -------------------------- | --------------------------------------------------------------------- | | contacts.upsert(contact) | Creates or updates a Contact. Needs the contacts:write Scope. | | contacts.get(lookup) | Reads one Contact, or null. Needs contacts:read. | | contacts.erase(contact) | Erases the Contact and everything about them. Needs contacts:erase. | | contacts.export(contact) | Reads everything held about the Contact. Needs contacts:export. |

get, erase and export all name the Contact the same way: { externalId } or { id }. erase and export also take a bare externalId string.

await throughline.contacts.upsert({
  externalId: 'user-42', // your own immutable id — prefer this form
  email: '[email protected]',
  name: 'Ada Lovelace',
  phone: '+4520123456', // E.164, required before an SMS can be sent
  timeZone: 'Europe/Copenhagen', // IANA, used to send at a local hour
});

const contact = await throughline.contacts.get({ externalId: 'user-42' });
const byId = await throughline.contacts.get({ id: contact!.id });

Key the Contact by an externalId you own — a user id from your database, not an email address. Email changes; externalId does not, and a Contact keyed by it survives the change.

Upserting with email alone is supported for the case where you have no such id. Such a Contact has no externalId, so the only handle on it is the id that upsert returns: store it beside the person in your own system, and erase or export the Contact by it when they ask.

const created = await throughline.contacts.upsert({ email: '[email protected]' });
// keep created.id

await throughline.contacts.export({ id: created.id });
await throughline.contacts.erase({ id: created.id });

Use the externalId form unless you genuinely have nothing to key on: it needs nothing stored.

Erasure and export

erase answers a right-to-erasure request. It is irreversible and it removes the Contact, their subscription states and their Events. It resolves true once the Contact is erased, and again on every later call for the same externalId or id, so a retry after a lost response still reports success. It resolves false only when no Contact in your Tenant ever matched.

await throughline.contacts.erase('user-42');

export answers a subject-access request. It resolves one record per line of the export — the Contact, then every subscription state, then every Event, oldest first — or null when there is no such Contact.

const records = await throughline.contacts.export('user-42');
// [{ type: 'contact', data: { … } }, { type: 'subscription', data: { … } }, { type: 'event', … }]

Events

await throughline.events.track({
  eventName: 'trial_started',
  contactExternalId: 'user-42',
  properties: { plan: 'pro' },
});

Name exactly one of contactId, contactExternalId or contactEmail to say who the Event is about, and optionally an organizationExternalId as well. An Event about an Organization alone names only organizationExternalId. A subject that does not exist rejects the call with ThroughlineRejectedEventsError rather than silently dropping the Event — upsert the Contact first, then track.

An EventName you have never sent before registers itself, and appears in the marketer's trigger picker from then on. That is deliberate — it is what lets a marketer build a Journey on an Event your code already sends — but it also means a typo becomes a permanent entry in their picker. Declaring an event catalogue is how you stop that happening.

Many Events at once

trackBatch sends one request per kind of subject named, so a batch that identifies every Contact the same way is a single request.

const result = await throughline.events.trackBatch([
  { eventName: 'invoice_paid', contactExternalId: 'user-42' },
  { eventName: 'invoice_paid', contactExternalId: 'user-43' },
]);

result.accepted; // 2
result.dedupKeys; // one per submitted event, in the order you gave them
result.rejected; // entries whose Contact did not exist, with the index you submitted them at

A request is all-or-nothing at the API: if one entry names a Contact that does not exist, none of that request's entries are recorded. Rather than lose the rest, trackBatch re-sends the entries that did resolve — under their original DedupKeys, so nothing is written twice — and reports the others in rejected, each carrying the index you submitted it at. accepted and rejected together account for every entry you passed. Every failure that is not a rejection throws, exactly as it does for track.

The event catalogue

Declare your EventNames and the properties they carry, and a misspelling becomes a compile error in your own repository instead of a stray EventDefinition in your marketer's trigger picker.

interface AcmeEvents {
  trial_started: { plan: 'free' | 'pro' };
  seat_added: { seats: number };
  page_viewed: Record<never, never>; // an Event that carries no properties
}

const throughline = new ThroughlineClient<AcmeEvents>({
  /* … */
});

await throughline.events.track({ eventName: 'trial_started', contactExternalId: 'user-42', properties: { plan: 'pro' } });
await throughline.events.track({ eventName: 'trail_started', contactExternalId: 'user-42', properties: { plan: 'pro' } });
//                                           ~~~~~~~~~~~~~~~ not assignable to 'trial_started' | 'seat_added' | 'page_viewed'
await throughline.events.track({ eventName: 'seat_added', organizationExternalId: 'acme', properties: { seats: 'twelve' } });
//                                                                                                     ~~~~~ not assignable to number

properties is required for an Event whose declaration has a required key and optional otherwise. The catalogue is entirely optional: a client constructed without one accepts any name and any properties, so it never stands between you and a first integration. It is also purely a compile-time device — the API stays permissive by design, and declaring a catalogue changes nothing about what it accepts.

IdentityHash, for the browser package

@throughlinehq/browser records Events from your frontend with a publishable key. A publishable key alone cannot say who is using it, so a browser must present an IdentityHash — proof, issued by your server, that this session is allowed to act for a particular Contact.

import { identityHash } from '@throughlinehq/server';

const hash = await identityHash({
  signingSecret: process.env.THROUGHLINE_SIGNING_SECRET!,
  externalId: user.id,
});

Or, if you already hold a client constructed with a signingSecret:

const hash = await throughline.identityHash(user.id);

It is HMAC-SHA256(signingSecret, externalId) as lowercase hex. Never compute it in the browser. Doing so ships the signing secret to every visitor, and any one of them can then record Events against anyone else's Contact. Compute it on your server, in the request that renders the page, and pass the result down with the page.

Find the signing secret and the key ID under Settings → API keys → Signing secret on any live key. Hand the browser both: it passes the hash as identityHash and the key ID as identityKeyId to @throughlinehq/browser. Rotating is then a deploy: create a second key, sign with it, then revoke the first. A request carries one Contact's hash, so every event in a batch must name the same Contact.

Because externalId never changes, a Contact's hash never changes either — compute it once at user creation and store it, or recompute it per request; both are correct. It does not expire. The remedy for a leaked hash is rotating the ApiKey's signing secret.

Coming from Segment or PostHog

| Your call today | Here | Note | | ------------------------------------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | analytics.identify(userId, traits) | contacts.upsert({ externalId: userId, email, name, phone, timeZone }) | email is required — it is how the platform reaches the person. Unknown traits are not accepted; the Contact has a fixed shape. | | analytics.track(userId, event, properties) | events.track({ eventName, contactExternalId: userId, properties }) | The Contact must exist first. | | analytics.track(...) for an anonymous visitor | no equivalent, by design | Every Event belongs to a known Contact or Organization. There are no anonymous Events and no anonymous id to alias later. | | analytics.group(userId, groupId, traits) | events.track({ …, organizationExternalId: groupId }) | Attribute an Event to an Organization by naming it alongside the Contact. | | analytics.page() / analytics.screen() | events.track({ eventName: 'page_viewed', … }) | A page view is an ordinary Event. From a browser, use @throughlinehq/browser. | | analytics.alias(...) | no equivalent, by design | There is no anonymous identity to merge into a known one. | | posthog.capture(distinctId, event, properties) | events.track({ eventName, contactExternalId: distinctId, properties }) | | | posthog.identify(distinctId, properties) | contacts.upsert({ externalId: distinctId, email, … }) | | | analytics.flush() | not needed | Nothing is buffered; every call is awaited. | | a test-mode write key | no equivalent | Use a separate staging Tenant. |

The shape to carry over: call upsert where you called identify, call track where you called track, and drop everything that existed to reconcile anonymous visitors with known users. Throughline has no anonymous Events, so none of that machinery has anything to do.

What the client does for you

  • Nothing is buffered. One awaited request per call, so a serverless function that freezes the moment it responds never loses an Event.
  • Retries are idempotent. Every Event carries a DedupKey — minted for you unless you supply one — reused byte for byte across retries, so a request that timed out but actually landed collapses server-side instead of enrolling a Contact in a Journey twice. Supply your own dedupKey to extend that guarantee across process restarts. Contact writes are idempotent by construction: upsert and erase land the same result however many times they arrive.
  • Retries stay inside the deduplication window. 5xx, 429 and network failures are retried with backoff; the whole call, token exchange included, is capped by timeoutMs (20 seconds by default, always under the API's 60-second window). 4xx responses are never retried.
  • The token lifecycle is handled. Concurrent calls on a cold client trigger one exchange, not one each; a 401 mid-flight re-exchanges once and replays the request with its original DedupKey.

Scopes

Each call needs a Scope on the ApiKey, and a key that lacks it fails with ThroughlineScopeError naming the Scope it wanted. Grant a key only what the service using it needs.

| Scope | Unlocks | | ----------------- | ----------------------------------- | | events:write | events.track, events.trackBatch | | contacts:write | contacts.upsert | | contacts:read | contacts.get | | contacts:erase | contacts.erase | | contacts:export | contacts.export |

Errors

Every failure is a subclass of ThroughlineError: ThroughlineAuthenticationError, ThroughlineScopeError, ThroughlineInvalidRequestError, ThroughlineRejectedEventsError, ThroughlineSubscriptionSuspendedError, ThroughlineRateLimitError, ThroughlineServerError and ThroughlineNetworkError. Neither secret appears in any of them.

ThroughlineSubscriptionSuspendedError means the company's subscription is suspended after failed payments: every write is refused until billing is fixed, so it is never retried. Reads and contacts.erase keep working.

An absent Contact is not an error: contacts.get and contacts.export resolve null, and contacts.erase resolves false for an externalId it has never seen.

Options

| Option | Default | What it is | | --------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | clientId | — | The ApiKey's ClientID. Not confidential. | | secret | — | The ApiKey's secret. Keep it in a secret store. | | baseUrl | https://server.api.throughline.dk | Base URL of the Throughline public API. Leave it unset. | | signingSecret | — | The same key's signing secret, used only by identityHash. | | fetch | global fetch | A replacement fetch, for tests or a specific runtime. | | timeoutMs | 20000 | Budget for one request, retries and token exchanges included. Must stay under 60000, so a retry still lands inside the deduplication window. trackBatch applies it per request, and may make more than one. | | maxRetries | 3 | How many times a failed call may be retried. |

Runtimes

ESM only, zero runtime dependencies, web-standard APIs only. Node 20 and above, and edge runtimes that provide fetch, AbortSignal.timeout, crypto.randomUUID and crypto.subtle.