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
| Node | 20 or newer |
| Types | included, written in TypeScript |
| Runtime dependencies | one (https-proxy-agent) |
Installation
npm install intempt-nodejs-sdk@2.0.0Pinned 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.
| value | where it comes from | required |
|---|---|---|
org | your organization name in the console URL | yes |
project | your project name in the console URL | yes |
apiKey | Settings → API keys, a public key in <prefix>.<secret> form | yes |
sourceId | Sources → your source, a 19-digit id | recommended |
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' },
});| option | type | notes |
|---|---|---|
userId | string | your identifier for a person |
accountId | string | your identifier for a company |
properties | object | event properties |
userAttributes | object | traits to set on the person |
accountAttributes | object | traits to set on the account |
timestamp | Date | number | epoch 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(); // booleanrecommend() 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.
| option | type | notes |
|---|---|---|
userId | string | your identifier for the person |
category | string | the consent category being granted or revoked |
validUntil | string | ISO date, epoch string, or 'unlimited' (default) |
email | string | recorded on the consent record |
message | string | the exact wording the person agreed to |
reason | string | why consent changed |
method | string | how it was collected — a form, a call, an import |
deviceInfo | string | free-form device description |
timestamp | Date | number | milliseconds 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 shutdownclose() 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
| response | behaviour |
|---|---|
| 413, batch > 1 | halve the batch size, retry |
| 413, batch = 1 | drop the event, log it, return the width to full |
| 429 | honour Retry-After, else exponential backoff |
| 5xx, 408, timeout | exponential backoff, floored at 100ms, capped at 10 minutes |
| other 4xx | drop the batch, log the status and body |
| 5 consecutive failures | stop 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,
});| option | type | notes |
|---|---|---|
feedId | string | required |
fields | string[] | required — product attribute names from your catalog schema |
limit | number | how many items to return |
productId | string | anchor 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
| type | sent as |
|---|---|
string, number, boolean | as-is |
Date | epoch milliseconds |
null | kept — it is a value you chose |
undefined | dropped, never reaches the wire |
| nested objects and arrays | as-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 timestamp | what happens |
|---|---|
| before 2010-01-01 | rejected by the API |
| 2010 to 2040 | stored as given |
| after 2040-01-18 | silently 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.
| option | type | default | what it does |
|---|---|---|---|
org | string | — | your organization name, from the console URL |
project | string | — | your project name, from the console URL |
apiKey | string | — | a public key, <prefix>.<secret> |
sourceId | string | — | the source events are attributed to. Keep it a string |
host | string | api.intempt.com | accepts host or host:port |
protocol | 'http' | 'https' | https | http is accepted for local testing only |
path | string | '' | prefix for every request path, for a reverse proxy |
timeout | number | 10000 | milliseconds, applied to connect and to read |
keepAlive | boolean | true | reuse one socket. Fixed at construction |
debug | boolean | false | log the method, path and full request body of every request. See the warning below |
maxRequestEvents | number | 50 | hard ceiling on events per request, independent of batch.size |
maxConcurrentRequests | number | 1 | requests one trackBatch() may have in flight. 1 keeps events in order |
batch | false | BatchOptions | — | off by default; see Delivery |
logger | Logger | none | anything with trace/debug/info/warn/error — console satisfies it |
agent | http.Agent | none | your 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:
| option | type | default | what it does |
|---|---|---|---|
size | number | 50 | events per request, capped by maxRequestEvents |
flushMs | number | 5000 | milliseconds before an incomplete batch is sent |
maxQueue | number | 10000 | events held in memory. Beyond this, new events are dropped and logged |
flushOnExit | boolean | true | drain 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:
| thrown | when | examples |
|---|---|---|
TypeError | a missing or wrong-typed argument | Intempt.init: "apiKey" is required, trackBatch[1]: event name is required, an invalid Date in timestamp |
RangeError | a value that is the right type but out of range | batch.maxQueue must be at least batch.size, timeout must be a positive number of milliseconds, a consent timestamp below the 2010 floor |
IntemptApiError | the request was made and failed | any 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);
}
}| property | meaning |
|---|---|
status | HTTP status, or undefined for a transport failure or timeout |
body | raw response body |
retryAfterMs | parsed from Retry-After when the server sent one |
cause | the underlying error, for a transport failure |
retryable | true for 408, 429, any 5xx, and transport failures |
Verifying an event arrived
- Send one
trackcall with auserIdyou can recognise. - Open Sources → your source → Live events in the console.
- The event appears with the name you sent.
If nothing appears, in order:
| symptom | cause |
|---|---|
| every call rejects with 401 | wrong apiKey, or the key belongs to another project |
| 404 on every call | wrong org or project — check the console URL |
| events land in the wrong source | sourceId was passed as a number and lost precision |
| commerce events accepted but no product shows | ingestion returns 201 for unknown product ids; the id must exist in your catalog |
| nothing arrives and nothing errors | you 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 migrateTwo 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*andchoosePersonalizationsBy*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.
