@throughlinehq/server
v0.1.3
Published
Record Throughline Events and manage Contacts from a JavaScript or TypeScript backend.
Maintainers
Readme
@throughlinehq/server
Record Throughline Events and manage Contacts from a JavaScript or TypeScript backend.
npm install @throughlinehq/serverimport { 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 atA 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 numberproperties 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
dedupKeyto 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,429and network failures are retried with backoff; the whole call, token exchange included, is capped bytimeoutMs(20 seconds by default, always under the API's 60-second window).4xxresponses are never retried. - The token lifecycle is handled. Concurrent calls on a cold client trigger one exchange, not
one each; a
401mid-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.
