SDKs
Node SDK
@loop/sdk-node exists because the events that decide whether a fix worked are usually the ones a browser cannot see: the webhook that says the subscription was cancelled, the backend signup, the nightly churn job.
Four things are deliberately different
| Difference | Why |
|---|---|
| It needs a secret key | An sk_live_ key, not the public browser one. The constructor refuses a wk_ key at boot, loudly. |
| distinctId is required | There is no browser to hold an anonymous id. Inventing one would attribute real events to a user who does not exist. |
| It throws on your bugs | A missing key or a missing distinctId throws at once. Transport failures never throw; they retry, then go to onError. |
| shutdown() is not optional | Events are batched. A Lambda, cron or container that exits without it loses the last batch. There is no pagehide to save you. |
Install and send
npm install @loop/sdk-nodeimport { Loop } from "@loop/sdk-node";
const loop = new Loop({ key: process.env.LOOP_SECRET_KEY! });
loop.track("subscription_cancelled", {
distinctId: "user_8412",
properties: { plan: "pro", reason: "price" },
// Pass this for anything replayed or backfilled. Without it every
// backfilled event is stamped with the moment the backfill ran.
timestamp: new Date(event.created * 1000)
});
await loop.shutdown();Never put a wk_live_ key on a server.
If its origin allowlist is locked, every event 403s and your server-side analytics silently does not exist. If it is not locked, it works, and you are now running your business on a credential printed in your own page's HTML with the one control that contained it turned off. The second case is the dangerous one, precisely because it works. So the constructor throws instead.
Configuration
| Option | What it does |
|---|---|
| key | Required. An sk_live_ key. Keep it in an environment variable; never ship it to a browser. |
| apiHost | Rarely needed. Defaults to the LOOP_API_HOST environment variable, then to https://api.loop.app, so a self-hosted Loop is one env var and no code. Set it only to override that for a single client. |
| flushAt | Send once this many events are queued. Default 20. |
| flushIntervalMs | …or once this long has passed, whichever comes first. Default 10000. |
| onError | Called when events are lost. Defaults to console.error, deliberately loudly. |
Knowing what was lost
Transport failures are retried three times with backoff. What survives that is handed to onError with the events still attached: yours to re-queue, log or bury, but you are always told.
const loop = new Loop({
key: process.env.LOOP_SECRET_KEY!,
onError: (error) => {
// error.message · error.status · error.events
logger.error(
{ status: error.status, lost: error.events.length },
error.message
);
if (error.status === 429) deadLetter.push(...error.events);
}
});error.status is an HTTP status, or the string network-error when the request never completed. 408, 429 and any 5xx are retried; everything else the server says is final. error.events is empty for a failed identify(): an identify carries traits, not events, so there is nothing to re-queue.
Shutting down
Await shutdown() before a short-lived process exits. It flushes what is queued and stops the timer. In a long-running server, call it on SIGTERM. Skipping it does not error; it just quietly loses the last batch, which is the worst kind of bug to find later.