Node.js SDK

Install @spectry/spectry, read feature flags and send events from your backend.

The Node.js SDK evaluates feature flags and A/B assignments on your server, and sends events from your backend. Use it when a decision has to be made before the page renders, when the logic must not be visible in client-side code, or when the thing you want to record — a payment settling, a job finishing — never happens in a browser at all.

It fetches your flag definitions once, caches them in memory and refreshes them in the background, so reading a flag is an in-process lookup. Nothing on your request path waits on Spectry.

Install

npm install @spectry/spectry

Or pnpm add @spectry/spectry / yarn add @spectry/spectry. Requires Node 18 or newer. It has no dependencies of its own, and ships both ESM and CommonJS builds with TypeScript types.

Create a client

Create one client per process, at startup.

const { SpectryClient } = require("@spectry/spectry");

const client = new SpectryClient({
  siteId: process.env.SPECTRY_SITE_ID,
  secretKey: process.env.SPECTRY_SECRET_KEY,
});

await client.init({ timeout: 1000 });

Using ESM, the import is import { SpectryClient } from "@spectry/spectry"; — everything else is identical.

This is not your write key. The server SDK needs a secret key (sk_live_…), created under Site settings → Security & API. It reads your flag definitions, so it must stay on the server. The wk_… key in your browser snippet will be rejected here.

init() resolves true when the configuration loaded and false when it did not. It does not throw: if Spectry is unreachable at boot your app still starts, every flag returns the fallback you specify, and the client keeps retrying in the background until it recovers. Pass { throwOnFailure: true } if you would rather fail fast.

Scope it to the current user

Create a per-request instance and store it on the request. This is cheap — it makes no network call — so per request is exactly right.

app.use((req, res, next) => {
  req.spectry = client.createScopedInstance({
    attributes: {
      id: req.user.id,
    },
  });
  next();
});

If you use Express, the bundled middleware does the same thing:

const { spectryMiddleware } = require("@spectry/spectry");

app.use(spectryMiddleware(client, {
  context: (req) => ({ attributes: { id: req.user.id } }),
}));
Create the client once, the scoped instance per request. A client per request would refetch your configuration every time and throw away the caching that makes flag reads free.

Targeting attributes

Attributes are what targeting rules match on. Replace the placeholders with your real values.

req.spectry = client.createScopedInstance({
  attributes: {
    id: req.user.id,
    url: req.originalUrl,
    path: req.path,
    host: req.hostname,
    country: "DE",
    browser: "chrome",
    deviceType: "desktop",
    plan: "scale",
    utmSource: "newsletter",
  },
});

id is the most important one. It is the bucketing seed: the same id always lands on the same side of a percentage rollout and in the same variation, on every server and across restarts. Without it there is no stable bucket, so anything below 100% falls back to its default.

attributesFromRequest(req) fills in url, path, host, the UTM parameters and a coarse deviceType and browser from the User-Agent, so you only add id and anything specific to you:

const { attributesFromRequest } = require("@spectry/spectry");

client.createScopedInstance({
  attributes: { ...attributesFromRequest(req), id: req.user.id },
});
Targeting rules are ANDed, and a rule needing an attribute you did not supply fails closed. A flag targeted at country: ["DE"] is off for a user with no country — not on for everyone.

Read a flag

On/off:

app.get("/", (req, res) => {
  if (req.spectry.isOn("new-checkout")) {
    res.send("Feature is enabled!");
  } else {
    res.send("Feature is disabled");
  }
});

With a value:

app.get("/", (req, res) => {
  const value = req.spectry.getFeatureValue("pricing-layout", "control");
  res.send("The feature value is: " + value);
});

The fallback is returned when the flag does not exist, has no value for this user, or the configuration has not loaded yet. Make it the behaviour you would want if Spectry were unreachable — that is exactly when it is used.

Send events

Log the events you care about so they can be used as experiment metrics and funnel steps.

// Simple (no properties)
req.spectry.logEvent("Payment Accepted");

// With custom properties
req.spectry.logEvent("Request Completed", {
  latency: 250,
});

logEvent() returns immediately and never throws — it queues in memory, and events are batched and sent in the background. It is designed to be safe to call on a checkout path, where neither added latency nor an exception is acceptable.

Because the queue is in memory, flush before the process exits or you will lose whatever is buffered:

process.on("SIGTERM", async () => {
  await client.close(); // stops refreshing and flushes queued events
  server.close();
});

On serverless platforms, where the process can be frozen the moment a response is sent, flush per request instead:

app.use(spectryMiddleware(client, {
  context: (req) => ({ attributes: { id: req.user.id } }),
  flushOnResponse: true,
}));

A/B test assignment

const assignment = req.spectry.getVariation("<experiment-id>");
// { experimentId, experimentName, variationId, variationName } | null

Assignment matches the browser SDK exactly, so a user bucketed on your server sees the same variation client-side — no flicker, and no visitor counted in two variations at once.

null means the user is not in the experiment — outside its traffic allocation, or excluded by targeting. That is not the same as being in the control group, and counting it as one will bias your results.

Caching & refresh

You do not need to manage this, but it helps to know what it does on a bad day:

  • On init(), definitions are fetched and cached in memory.
  • Every 60 seconds the client re-checks. When nothing has changed the response is empty, so an idle poll is nearly free.
  • If a refresh fails, the client keeps serving the last known configuration and retries. It never falls back to "everything off".
  • If the key is rejected, it logs once and stops polling — a bad key will not fix itself, and repeating the message every minute only buries it.

A flag change in the dashboard therefore reaches your servers within about a minute. Call client.refresh() if you need it sooner.

Checking it works

client.getStatus() reports what the client is holding — useful in a health check endpoint:

{ ready: true, version: 'W/"a1b2…"', flagCount: 12, experimentCount: 3,
  lastUpdatedAt: 1700000000000, consecutiveFailures: 0, queuedEvents: 0 }

ready: false long after boot means the configuration never loaded — check the site ID, the key and its scopes. A rising consecutiveFailures means refreshes are failing while cached values continue to be served.

To understand a single decision, getFeature() explains itself:

req.spectry.getFeature("new-checkout");
// { value: false, on: false, off: true, source: "rollout" }

source tells you why: unknownFlag, notReady, disabled, targeting, rollout, variant or enabled. It is the fastest way to answer "why am I not seeing this feature" — see the full reference.


Put this to work on your own site.

Heatmaps, session replays, funnels and experiments in one platform. Set it up in minutes, no credit card needed, and 5,000 sessions a month are free forever.