Intempt Docs
Developer DocsSDK

Node.js SDK

Send events, identity, consent and commerce from your Node server, and read recommendations on the request path.

Node.js SDK

Server-side client for Node. Data in, decisions out.

This is not the browser SDK. It holds no per-user state: every call takes its identifier explicitly, so one client instance is shared across every request and every user. For the browser, use the JavaScript SDK.

Requirements

Node20 or newer
Typesincluded, written in TypeScript
Runtime dependenciesone (https-proxy-agent)

Installation

npm install intempt-nodejs-sdk@2.0.0

Pinned deliberately. 2.0.0 is the current release; an unpinned install picks up whatever is latest at build time, which is how a working integration changes behaviour without anyone editing it.

The package is intempt-nodejs-sdk. The shorter intempt package on npm is a retired publish that stops at the 1.x SDK class — installing it and following this page fails at the first import with SyntaxError: Named export 'Intempt' not found.

Setup

You need three values from the Intempt console, and optionally a fourth.

valuewhere it comes fromrequired
orgyour organization name in the console URLyes
projectyour project name in the console URLyes
apiKeySettings → API keys, a public key in <prefix>.<secret> formyes
sourceIdSources → your source, a 19-digit idrecommended

Keep the key in the environment, never in source. It is a credential even though it is called public: it can write to your project.

export INTEMPT_API_KEY='your-prefix.your-secret'

sourceId is a 19-digit number that exceeds JavaScript's safe integer range. Always keep it a string. Number("1841710181319290880") silently rounds to 1841710181319290900, which addresses a different source, and nothing errors.

Initialization

import { Intempt } from 'intempt-nodejs-sdk';

const intempt = Intempt.init({
  org: 'my-org',
  project: 'my-project',
  apiKey: process.env.INTEMPT_API_KEY!,
  sourceId: '684508596718616576',
});

Create it once for the process and share it. It is safe to use concurrently.

Nothing is sent at init. The first request happens on your first call.

Capture

track

await intempt.track('purchase', {
  userId: 'user@example.com',
  properties: { total: 99.99, currency: 'USD' },
});
optiontypenotes
userIdstringyour identifier for a person
accountIdstringyour identifier for a company
propertiesobjectevent properties
userAttributesobjecttraits to set on the person
accountAttributesobjecttraits to set on the account
timestampDate | numberepoch milliseconds, defaults to now

At least one of userId or accountId is required. Every method rejects on failure — nothing is swallowed.

trackBatch

await intempt.trackBatch([
  { event: 'page_view', userId: 'u1', properties: { path: '/pricing' } },
  { event: 'signup', userId: 'u2' },
]);

Chunked so one oversized call cannot become one oversized request. An error names the offending index: trackBatch[1]: event name is required.

Commerce

await intempt.ecommerce.productViewed({ userId: 'u1', productId: 'sku-1' });
await intempt.ecommerce.addedToCart({ userId: 'u1', productId: 'sku-1', quantity: 2 });
await intempt.ecommerce.ordered({
  userId: 'u1',
  products: [{ productId: 'sku-1', quantity: 2 }, { productId: 'sku-2' }],
});

These send the reserved event names the platform recognises for commerce reporting — Product viewed, Added to cart, Product ordered — so they cannot be typo'd.

Identity and lifecycle

The only two identifiers are userId and accountId, and both are values you already own.

await intempt.identify({ userId: 'u1', traits: { plan: 'pro' } });
await intempt.group({ userId: 'u1', accountId: 'acme', attributes: { tier: 'ent' } });
await intempt.alias({ userId: 'new-id', previousUserId: 'old-id' });

There is no reset(), and that is deliberate. One client is shared across every request, and each call carries its own identifier, so there is nothing to reset. If you are looking for it, you are probably holding one client per user — don't; hold one per process.

profileId and masterId are not part of this SDK. profileId is the anonymous id the browser SDK mints and keeps on the device, so a server that invents one creates an orphan profile that never stitches to a real visitor. masterId is assigned internally after identity resolution and cannot be read from a server. Send userId as soon as you know it and let the platform stitch.

Opt in / out

intempt.optOut();          // suppresses track, batch, commerce and consent
intempt.optIn();
intempt.isOptedIn();       // boolean

recommend() is unaffected: it sends an identifier you already hold and returns a decision rather than storing anything.

The gate is applied when events are sent, not only when they are captured, so anything buffered before optOut() is discarded rather than transmitted by a later flush. A consent revocation between capture and flush is honoured.

Consent records are a separate, explicit API:

await intempt.consent.grant({ userId: 'u1', category: 'marketing' });
await intempt.consent.revoke({ userId: 'u1', reason: 'user requested deletion' });

Both take the same options. userId is required — a blank one throws consent: userId must be a non-empty string. Everything else is optional.

optiontypenotes
userIdstringyour identifier for the person
categorystringthe consent category being granted or revoked
validUntilstringISO date, epoch string, or 'unlimited' (default)
emailstringrecorded on the consent record
messagestringthe exact wording the person agreed to
reasonstringwhy consent changed
methodstringhow it was collected — a form, a call, an import
deviceInfostringfree-form device description
timestampDate | numbermilliseconds in, seconds on the wire — see Timestamps

Delivery

By default each call sends one request and the promise resolves when the server responds. Nothing is buffered, so there is nothing to lose on exit — which makes the default safe in Lambda and other short-lived processes.

For a long-lived server, turn on batching:

const intempt = Intempt.init({
  org: 'my-org',
  project: 'my-project',
  apiKey: process.env.INTEMPT_API_KEY!,
  batch: { size: 50, flushMs: 5_000, maxQueue: 10_000 },
});

await intempt.flush();   // send what is buffered now
await intempt.close();   // drain and release, at shutdown

close() drains for at most 30 seconds, then stops retrying and logs how many events it gave up on. flush() is not bounded. Both are safe to call with batching off, where flush() is a no-op — so an unconditional shutdown hook is fine.

intempt.buffered is how many events are still queued, and intempt.config is a frozen snapshot of the resolved options. Mutating the snapshot cannot change the client.

Retry policy

responsebehaviour
413, batch > 1halve the batch size, retry
413, batch = 1drop the event, log it, return the width to full
429honour Retry-After, else exponential backoff
5xx, 408, timeoutexponential backoff, floored at 100ms, capped at 10 minutes
other 4xxdrop the batch, log the status and body
5 consecutive failuresstop batching and report how many events are stranded

Recovery after a halving is deliberately slow: the width doubles only after 10 consecutive sends that filled it, because a 413 usually reflects payload size rather than a transient condition, and returning to full immediately just alternates 413/200 forever. So throughput does not bounce back on the next successful request, and each doubling needs its own run of ten.

The single-event drop is the exception. There the offending event is gone, so the width was never the problem and it returns to full at once.

Delivery is at-least-once, not exactly-once. A retry after a lost response re-sends events the server may already have stored, and ingestion has no idempotency key. Leave batch off if you would rather a failure surface to your code than be retried, and de-duplicate downstream if exact counts matter.

The buffer is in memory. A hard crash loses it.

Personalization

Recommendations from a feed, on the request path:

const feed = await intempt.recommend({
  userId: 'user@example.com',
  feedId: '5292',
  fields: ['id', 'title'],
  limit: 5,
});
optiontypenotes
feedIdstringrequired
fieldsstring[]required — product attribute names from your catalog schema
limitnumberhow many items to return
productIdstringanchor the feed to a product, for "related to this" placements

Pass exactly one of userId or accountId — the feeds API resolves a single entity, so passing both is an error rather than a preference.

Experiments and personalizations are deliberately absent from this SDK: they resolve a web experience against a page, and a server has no page to modify. Use the JavaScript SDK for those.

Treat a recommendation as an enhancement. If the call fails, degrade to your default ordering rather than failing the page.

Property types

typesent as
string, number, booleanas-is
Dateepoch milliseconds
nullkept — it is a value you chose
undefineddropped, never reaches the wire
nested objects and arraysas-is

Timestamps

timestamp accepts a Date or epoch milliseconds on every tracking call, and it is a backfill mechanism — the event store keeps your value and records arrival separately. An invalid Date or a non-finite number throws a TypeError before anything is sent.

On tracking calls the SDK validates the shape of what you pass, not its range. The bounds below are the platform's, applied server-side:

your timestampwhat happens
before 2010-01-01rejected by the API
2010 to 2040stored as given
after 2040-01-18silently replaced with the server's clock

Sending seconds where milliseconds were meant lands past 2040, is quietly rewritten to now, and the event looks like it just happened — with no error. The reverse mistake fails loudly. Nothing client-side catches this: check the unit before you backfill.

Consent is the exception, in two ways. consent.grant() and consent.revoke() take the same Date-or-milliseconds value, but the SDK converts it to epoch seconds on the wire, because the consent API compares timestamp * 1000 against millisecond bounds. And the 2010 floor is enforced client-side there — below it you get a RangeError naming the threshold, thrown before any request leaves the process.

Configuration reference

Every option Intempt.init(...) accepts. Only the first three are required.

optiontypedefaultwhat it does
orgstringyour organization name, from the console URL
projectstringyour project name, from the console URL
apiKeystringa public key, <prefix>.<secret>
sourceIdstringthe source events are attributed to. Keep it a string
hoststringapi.intempt.comaccepts host or host:port
protocol'http' | 'https'httpshttp is accepted for local testing only
pathstring''prefix for every request path, for a reverse proxy
timeoutnumber10000milliseconds, applied to connect and to read
keepAlivebooleantruereuse one socket. Fixed at construction
debugbooleanfalselog the method, path and full request body of every request. See the warning below
maxRequestEventsnumber50hard ceiling on events per request, independent of batch.size
maxConcurrentRequestsnumber1requests one trackBatch() may have in flight. 1 keeps events in order
batchfalse | BatchOptionsoff by default; see Delivery
loggerLoggernoneanything with trace/debug/info/warn/errorconsole satisfies it
agenthttp.Agentnoneyour own agent, for a private CA, mTLS, or an explicit proxy policy. When set, keepAlive, HTTPS_PROXY and HTTP_PROXY are all ignored and the agent is used verbatim

batch:

optiontypedefaultwhat it does
sizenumber50events per request, capped by maxRequestEvents
flushMsnumber5000milliseconds before an incomplete batch is sent
maxQueuenumber10000events held in memory. Beyond this, new events are dropped and logged
flushOnExitbooleantruedrain on beforeExit

debug: true writes event bodies to your logs. Every property you send is logged verbatim — userId, email addresses, and whatever is in properties and userAttributes. Nothing is redacted; the only value the SDK masks anywhere is your API secret.

Treat a debug log as data of the same sensitivity as the events themselves: do not leave it on in production, and do not ship it to a log service that is out of scope for your privacy commitments.

setConfig() changes the transport-level options on a live client — host, protocol, path, timeout, debug, logger, maxRequestEvents, maxConcurrentRequests.

Anything the connection is built from is fixed at construction: org, project, apiKey, sourceId, batch, keepAlive and agent are rejected by the type signature, so passing one is a compile error rather than a runtime surprise.

Errors and troubleshooting

Request failures reject with IntemptApiError. Bad arguments and bad config do not — they throw synchronously as a TypeError or RangeError, before any request is attempted, so Intempt.init({ ... }) with an unusable option throws rather than returning a client that fails later:

thrownwhenexamples
TypeErrora missing or wrong-typed argumentIntempt.init: "apiKey" is required, trackBatch[1]: event name is required, an invalid Date in timestamp
RangeErrora value that is the right type but out of rangebatch.maxQueue must be at least batch.size, timeout must be a positive number of milliseconds, a consent timestamp below the 2010 floor
IntemptApiErrorthe request was made and failedany non-2xx, plus transport failures and timeouts

A catch that only handles IntemptApiError therefore swallows every configuration mistake. Validate config at startup and let those throw.

Request failures:

import { IntemptApiError } from 'intempt-nodejs-sdk';

try {
  await intempt.track('purchase', { userId: 'u1' });
} catch (error) {
  if (error instanceof IntemptApiError) {
    console.error(error.status, error.body, error.retryable);
  }
}
propertymeaning
statusHTTP status, or undefined for a transport failure or timeout
bodyraw response body
retryAfterMsparsed from Retry-After when the server sent one
causethe underlying error, for a transport failure
retryabletrue for 408, 429, any 5xx, and transport failures

Verifying an event arrived

  1. Send one track call with a userId you can recognise.
  2. Open Sources → your source → Live events in the console.
  3. The event appears with the name you sent.

If nothing appears, in order:

symptomcause
every call rejects with 401wrong apiKey, or the key belongs to another project
404 on every callwrong org or project — check the console URL
events land in the wrong sourcesourceId was passed as a number and lost precision
commerce events accepted but no product showsingestion returns 201 for unknown product ids; the id must exist in your catalog
nothing arrives and nothing errorsyou called optOut(), or batching is on and you never called flush()

Ingestion answers 201 for unknown accounts and products. A made-up productId therefore returns success and proves nothing — always test with an id that exists in your catalog.

Migrating from 1.x

The 1.x SDK class still works and forwards to the new client, with a one-time deprecation warning. It will be removed in 3.0.

import { SDK } from 'intempt-nodejs-sdk';
const legacy = new SDK(org, project, apiKey, sourceId);
const modern = legacy.v2;   // the new client, while you migrate

Two things changed that matter:

  • Errors reject instead of being swallowed. Code that ignored the return value of a 1.x call now needs to handle a rejection.
  • chooseExperimentsBy* and choosePersonalizationsBy* are gone. They were browser features on a server SDK. They reject with a message naming the method.

Frequently asked

What happens to an event that is too large? The platform answers 413. With more than one event in the batch the SDK halves the batch and retries, so an oversized batch still gets through as smaller ones. A single event that is still refused is dropped and logged — it cannot be made smaller. After three consecutive drops the SDK says so once, rather than logging every one.

Is event order preserved? Within one client, yes. The queue is FIFO and one request is in flight at a time, so events leave in the order you recorded them. Order across separate clients or processes is not guaranteed, and the platform orders by your timestamp rather than by arrival.

Is there an EU or regional endpoint? No. There is one endpoint, api.intempt.com, and every project is served from it. The host option exists to point at a reverse proxy or a test server, not at a region. If data residency matters to your deployment, raise it before you integrate rather than after.

What happens if the process dies with events buffered? They are lost. The buffer is in memory and nothing is written to disk. close() drains it first, and flushOnExit covers a normal exit, but neither survives SIGKILL or a crash. Leave batching off where the process is short-lived — that is the default.

How do I send historical events? Pass timestamp explicitly. It is a genuine backfill mechanism between 2010 and 2040, and the event store keeps your value while recording arrival separately. See Timestamps for the two ways this goes wrong.

Can I use one client for everything? The client is safe to use concurrently. Share one across every request — the client holds no per-user state, and every call carries its own identifier. That is why there is no reset().

Does optOut() delete data already sent? No. It stops this client sending anything further, and it is applied at send time, so anything already buffered is discarded rather than transmitted. Deleting stored data is a separate request — see the consent API and your DSAR process.

Why did my event not appear, with no error? Four causes, in the order they happen: you called optOut(); batching is on and you never flushed; the sourceId lost precision and the event went to a different source; or the timestamp was past 2040 and was rewritten to now, so the event is there but not where you were looking.

On this page