TypeScript SDK
One package with six entry points. Import the one that matches your runtime, and the tree-shaken build leaves the rest out of your bundle.
Where it is published
@prodantix/sdk on npm.
Install
bun add @prodantix/sdkEntry points
Every subpath ships its own ESM and CommonJS build with its own types. The root is runtime agnostic, and the others add what one runtime needs.
| Subpath | What it exports |
|---|---|
| @prodantix/sdk | ProdantixClient, createClient, evaluateFlag, FlagsClient, MessagesClient |
| @prodantix/sdk/web | createWebClient, installAutocapture, installLinkCarry, installSessionReplay, WebTransport |
| @prodantix/sdk/node | createNodeClient, readSharedIdentity |
| @prodantix/sdk/csp | prodantixConnectSrc, prodantixImgSrc, prodantixScriptSrc |
| @prodantix/sdk/chat | mountChat, ChatClient, ChatPanel, resolveAppearance |
| @prodantix/sdk/messenger | fetchMessengerConfig, cachedMessengerConfig, MESSENGER_CACHE_KEY |
@prodantix/sdk
The client, the flag evaluator, and the storage and transport interfaces. Nothing here touches the DOM, so you can supply your own storage, transport, clock or id factory and run it anywhere.
import { createClient, MemoryStorage } from '@prodantix/sdk';
const prodantix = createClient({
apiKey: PRODANTIX_PUBLIC_KEY,
host: 'https://eu.api.prodantix.com',
storage: new MemoryStorage(),
});
prodantix.capture('order.completed', { properties: { amount: 4200, currency: 'XAF' } });
await prodantix.flush();@prodantix/sdk/web
Browser wiring over the same client: persistence in local storage, delivery by beacon when the page hides, and pageview plus click autocapture. Session replay stays off until you give it a replay host.
import { createWebClient } from '@prodantix/sdk/web';
const prodantix = createWebClient({
apiKey: PRODANTIX_PUBLIC_KEY,
host: 'https://eu.api.prodantix.com',
flagsHost: 'https://api.prodantix.com',
cookieDomain: 'example.com',
autocaptureOptions: { clicks: true, pageviews: true },
});
prodantix.identify('user-42', { set: { plan: 'pro' } });
if (await prodantix.isFeatureEnabled('new-checkout')) {
// ...
}
// Every per-flag read records one $feature_flag_called exposure.
const variant = await prodantix.getVariant('new-checkout');
if (variant?.key === 'treatment') {
// ...
}
// A flag targeting a cohort reads this user's memberships from the store;
// pass them yourself to skip the lookup.
if (await prodantix.isFeatureEnabledLocal('members', { cohorts: ['<cohort id>'] })) {
// ...
}On the web the visitor's anonymous id and session live in one first-party cookie on your registrable domain, so xyz.com, app.xyz.com and console.xyz.com see one person: the marketing visit, the sign-up and the first session in the app land on the same timeline once the app identifies them. The identified id stays per origin in local storage and never enters the cookie, because every host under the domain can read it. The domain is found by asking the browser, or set with cookieDomain; a site whose subdomains belong to different tenants must set it to its own host. Pass sharedStorage to write no cookie, for instance until consent is given, and local storage remains the fallback when cookies are refused. A session id is minted per visit, rotated after thirty minutes idle or twenty-four hours, stamped on every event and shared with session replay.
A cookie cannot cross a registrable domain, so a visitor who follows a link from one site to another would be two visitors. Pass linkedDomains, empty by default, and the client decorates outbound links to those domains, on click and auxclick, with a _pdx parameter carrying three things: the anonymous id, the session id and the moment the session started, so the thirty-minute window and the twenty-four-hour cap continue across the hop. Only the anonymous id travels, never an identified one, because the destination identifies the person itself. A link to the same registrable domain, one that already carries _pdx, and mailto, tel, javascript and hash links are all left alone. On arrival createWebClient reads _pdx before identity is built, writes the carried id and session into the shared store, and strips the parameter with history.replaceState so it neither appears in $current_url nor rides onward; a parameter whose three parts do not parse is ignored and the address left as it was. If this host already held a different anonymous id the client sends $create_alias, so what it recorded earlier folds into the carried id, and a host that has already identified a person keeps that person: the carried id becomes their anonymous id, never their distinct id.
import { createWebClient } from '@prodantix/sdk/web';
const prodantix = createWebClient({
apiKey: PRODANTIX_PUBLIC_KEY,
host: 'https://eu.api.prodantix.com',
linkedDomains: ['example.com', 'example-app.com'],
});
// A link to example-app.com now leaves as
// https://example-app.com/pricing?_pdx=<anonymousId>~<sessionId>~<startedAt>
// and the arriving page adopts it before it builds an identity of its own.
// The visitor's answer, from your own consent banner.
prodantix.setConsent({ identityLink: false });
prodantix.consent(); // { identityLink: false }The visitor's own answer about that join rides the same shared store, under pdx..consent, so every subdomain and a server reading the cookie see one answer. Record it with setConsent({ identityLink: false }), read it back with consent(), and seed it with the consent option when your app already holds the answer: the consent banner is yours to build, and prodantix honours what it collects. While identityLink is false, identify sends no anonymous id to join to the person, and a link to a linked domain is left undecorated, since carrying the id across is the same join by another route. Unset or true allows both, subject to the project's own setting in the console, which the state worker enforces rather than anything the browser sends. Changing the answer takes effect on the next identify and the next click; nothing already joined is unjoined.
@prodantix/sdk/node
The same client with server defaults. Events are buffered and flushed in the background, so call shutdown before the process exits or the last batch never leaves.
import { createNodeClient } from '@prodantix/sdk/node';
// A node client streams flag changes over Socket.IO by default and refetches
// the snapshot on push; streamFlags: false leaves only the 30s poll.
const prodantix = createNodeClient({
apiKey: process.env.PRODANTIX_PUBLIC_KEY,
host: 'https://eu.api.prodantix.com',
streamFlags: true,
});
prodantix.capture('job.completed', { properties: { queue: 'emails' } });
await prodantix.shutdown();A sign-in that completes in a server redirect renders no page, so the browser's own identify never runs there. readSharedIdentity(cookieHeader, apiKey) reads the cookie the web client writes and answers the anonymous id, the session it is in and the visitor's consent, or null when the cookie is absent or is not this SDK's. link(distinctId, anonymousId, traits) then sends the one $identify the browser would have sent, for the ids given, and leaves this client's own identity untouched. Bomdisoft's login calls both at sign-in completion, inside the single function all eight of its sign-in sites already call: that is where the person's subject first becomes known on the server while the cookie is still on the request. Six of those sites are server actions with no request object, which is why the function takes a cookie header string rather than a request. Skip link when the answer read back is identityLink false.
import { createNodeClient, readSharedIdentity } from '@prodantix/sdk/node';
const prodantix = createNodeClient({
apiKey: PRODANTIX_PUBLIC_KEY,
host: 'https://eu.api.prodantix.com',
});
// At sign-in completion, where the person is known and the browser's
// cookie is still on the request.
export const completeSignIn = (sub: string, cookieHeader: string): void => {
const shared = readSharedIdentity(cookieHeader, PRODANTIX_PUBLIC_KEY);
if (shared !== null && shared.consent?.identityLink !== false) {
prodantix.link(sub, shared.anonymousId);
}
};@prodantix/sdk/csp
The content security policy sources an app needs to reach Prodantix. It imports nothing from React or the browser, so a build config file can read it directly.
import { prodantixConnectSrc, prodantixImgSrc, prodantixScriptSrc } from '@prodantix/sdk/csp';
const hosts = {
ingest: 'https://eu.api.prodantix.com',
edge: 'https://eu.edge.prodantix.com',
replay: 'https://eu.replay.prodantix.com',
};
const policy = [
`connect-src ${prodantixConnectSrc(hosts)}`,
`script-src ${prodantixScriptSrc(hosts)}`,
`img-src ${prodantixImgSrc(hosts, ["'self'", 'data:'])}`,
].join('; ');The support widget shows your team's pictures from the edge and draws the files visitors send from blob: addresses, so the policy's img-src needs both. prodantixImgSrc adds them to the image sources you already allow. Without them the browser blocks those images and says so only in its own console.
@prodantix/sdk/chat
The support widget. Mount it and you get a handle that opens, closes, destroys and identifies. It renders inside a closed shadow root, so the styles of the host page never reach it.
import { mountChat } from '@prodantix/sdk/chat';
const messenger = mountChat({
apiKey: PRODANTIX_PUBLIC_KEY,
host: 'https://eu.edge.prodantix.com',
distinctId: prodantix.distinctId,
});
document.querySelector('#help')?.addEventListener('click', () => messenger.open());Every visitor message carries where it was written. The panel reads location and document.title at the moment of sending and posts host, path and title, with path the pathname alone: never the query and never the fragment, because a reset link, a magic link or a checkout return carries its token there and an agent's inbox is no place for it. The panel draws none of it, so the visitor's thread stays their own words, and the edge's answer back to the SDK omits it too, so a page reading its own thread cannot see what the console sees. Where it does show is the inbox, as a muted line under an inbound turn.
@prodantix/sdk/messenger
The settings reader behind the widget: it fetches the messenger configuration for a project, caches it, and reports whether the project has the messenger switched on. The self-executing snippet is built separately and is not reachable through this subpath.
import { fetchMessengerConfig } from '@prodantix/sdk/messenger';
const config = await fetchMessengerConfig({
apiKey: PRODANTIX_PUBLIC_KEY,
host: 'https://eu.edge.prodantix.com',
});
if (config?.enabled) {
// the project has the messenger switched on
}