# Node.js SDK

> Send events from Node.js, serverless functions and Cloudflare Workers with @clickclacks/node: install, configuration, track and identify, flushing before exit, batching and retries, errors, and testing.

- Canonical URL: https://clickclacks.io/docs/node
- Section: SDKs
- Last updated: 2026-09-26

@clickclacks/node sends events from Node.js, serverless functions and Cloudflare Workers. It batches, compresses and retries safely, so you call track and move on.

- Node 18 or later, and Cloudflare Workers (no `nodejs_compat` flag needed). Bun and Deno work too, but aren’t promised.
- No runtime dependencies. ESM, CommonJS and TypeScript types included. MIT licence.
- Every event gets an `insert_id`, so a retry never counts twice.

## Install {#install}

```bash title="terminal"
npm install @clickclacks/node
# or: pnpm add @clickclacks/node · yarn add @clickclacks/node
```

You need a server key (`cks_live_…`) from a server source’s page in the app. It’s shown once. Keep it in an environment variable; the API refuses browser requests. See [Server keys](https://clickclacks.io/docs/server-side.md#keys).

## Quick start {#quick-start}

```ts title="server.ts"
import { ClickClacks } from '@clickclacks/node'

const clickclacks = new ClickClacks({ key: process.env.CLICKCLACKS_SERVER_KEY! })

clickclacks.track({
  event: 'Subscription started',
  distinctId: 'user_8412',
  properties: { plan: 'pro', $revenue: 49, $currency: 'USD' },
})

clickclacks.identify({
  distinctId: 'user_8412',
  properties: { plan: 'pro', seats: 12 },
})

process.on('SIGTERM', async () => {
  await clickclacks.shutdown()
  process.exit(0)
})
```

With CommonJS:

```js title="server.js"
const { ClickClacks } = require('@clickclacks/node')
```

`track` and `identify` return straight away; events are sent in the background. Create one client per process and reuse it.

## Options {#options}

Passed to `new ClickClacks(options)`. Out-of-range values throw when the client is created.

| Option | Default | What it does |
| --- | --- | --- |
| `key` | required | A secret server key, `cks_live_…` (older `sk_live_…` keys also work). A browser `pk_live_` key throws. |
| `host` | `https://app.clickclacks.io` | Where to send. Custom tracker domains don’t serve the server API, so leave it unless told otherwise. |
| `flushAt` | `100` | Items per batch, 1–500. Reaching it sends at once. |
| `flushInterval` | `5000` | Milliseconds between automatic flushes. `0` turns the timer off, for Workers and serverless. |
| `maxQueueSize` | `10000` | Items held in memory, queued or in flight. When full, new items are dropped. |
| `maxRetries` | `6` | Retries after the first attempt. |
| `requestTimeout` | `10000` | Milliseconds before a request is abandoned and retried. |
| `onError` | `console.warn` | Receives a `ClickClacksError`. See [Errors](https://clickclacks.io/docs/node.md#errors). |
| `fetch` | global `fetch` | Replace it for proxies or tests. |

## track {#track}

```ts title="server.ts"
clickclacks.track({
  event: 'Invoice paid',                  // 1–128 characters, not starting with $
  distinctId: 'user_8412',                // or anonymousId, or both
  anonymousId: 'per_3f9a…',               // optional: a browser's anonymous ID
  sessionId: 'ses_7c21…',                 // optional: a browser session to join
  timestamp: new Date(),                  // optional: Date, ISO string or epoch ms
  insertId: 'inv_2291_paid',              // optional: generated when left out
  properties: { amount_cents: 4900 },
})
```

- One of `distinctId` (your user ID) or `anonymousId` (a browser’s `per_…` ID) is required. [Identity from the server](https://clickclacks.io/docs/server-identity.md) explains which person each lands on.
- `timestamp` defaults to the moment you call `track`. The API accepts 7 days back to 10 minutes ahead.
- In `properties` you may send `$ip`, `$user_agent`, `$country`, `$current_url`, `$groups`, `$revenue` and `$currency`; other `$` keys are refused by the API. See [Properties](https://clickclacks.io/docs/api.md#properties).
- Fields are camelCase here and snake_case on the wire (`distinctId` → `distinct_id`).

## identify {#identify}

```ts title="server.ts"
clickclacks.identify({
  distinctId: 'user_8412',                // required
  anonymousId: 'per_3f9a…',               // optional: link this browser to the user
  properties: { plan: 'pro', company: 'Acme' },
})
```

Records traits for a user and, with `anonymousId`, links that browser to them, exactly like the browser tracker’s `identify`. Identify calls are free.

## Flushing before exit {#flushing}

The automatic flush timer doesn’t keep a Node process alive. So before a process ends, deliver what’s queued:

- **`shutdown({ timeout = 10000 })`** flushes, stops the timer and refuses new calls. After `timeout` milliseconds it stops retrying and reports what’s left as `shutdown_timeout`. Safe to call twice. Use it on `SIGTERM`, as in the quick start.
- **`flush()`** sends everything queued now and resolves when it’s delivered, refused or given up on. It never rejects; problems go to `onError`.

```ts title="job.ts"
// A script, cron job or queue worker: flush before it ends.
await runNightlyExport()
clickclacks.track({ event: 'Export finished', distinctId: 'user_8412' })
await clickclacks.shutdown()
```

## Cloudflare Workers {#workers}

Workers can’t keep timers between requests. Turn the timer off with `flushInterval: 0`, and flush at the end of each request with `flushWith(ctx)`, which is `ctx.waitUntil(clickclacks.flush())`: the response goes out at once and delivery finishes in the background.

```ts title="worker.ts"
import { ClickClacks } from '@clickclacks/node'

let clickclacks: ClickClacks | undefined

export default {
  async fetch(request, env, ctx) {
    clickclacks ??= new ClickClacks({ key: env.CLICKCLACKS_SERVER_KEY, flushInterval: 0 })
    clickclacks.track({ event: 'Export finished', distinctId: 'user_8412' })
    clickclacks.flushWith(ctx) // ctx.waitUntil(clickclacks.flush())
    return new Response('ok')
  },
} satisfies ExportedHandler<{ CLICKCLACKS_SERVER_KEY: string }>
```

## Serverless functions {#serverless}

On AWS Lambda and similar platforms, the runtime can freeze as soon as your handler returns. Turn the timer off and `await flush()` before returning. Where the platform offers a `waitUntil`, pass it instead: `flushWith` takes any object with a `waitUntil` method.

```ts title="handler.ts"
import { ClickClacks } from '@clickclacks/node'

// Outside the handler, so warm invocations reuse it.
const clickclacks = new ClickClacks({ key: process.env.CLICKCLACKS_SERVER_KEY!, flushInterval: 0 })

export async function handler(event) {
  clickclacks.track({ event: 'Report generated', distinctId: event.userId })
  await clickclacks.flush() // before returning: the runtime may freeze afterwards
  return { statusCode: 200 }
}
```

## How delivery works {#delivery}

- **Batching.** A batch goes out when the queue reaches `flushAt` or after `flushInterval`, split so each request stays under the API’s 1 MiB limit. Batches are sent one at a time, in order. Batching also matters for [rate limits](https://clickclacks.io/docs/api.md#rate-limits), which count events in blocks of 100.
- **Compression.** Bodies over 1 KiB are gzipped with the built-in `CompressionStream`.
- **Idempotency.** Every item gets an `insert_id` when it’s queued, and a retry resends the same bytes. The API stores repeats once per source, so retrying is always safe.
- **Retries.** Network errors, timeouts, `408`, `429` and `5xx` are retried with exponential backoff (500 ms × 2ⁿ, capped at 30 seconds) and full jitter. A `Retry-After` from the API always wins, up to 5 minutes. A `413` splits the batch in half and resends. Other `4xx` answers are never retried.
- **Queue cap.** At `maxQueueSize`, new items are dropped, so an outage loses the newest events rather than ones already queued.
- Requests carry `User-Agent: clickclacks-node/<version>`, so events record the library that sent them.

## Errors {#errors}

Calls never throw (only the constructor does, for bad options). Problems go to `onError` as a `ClickClacksError` with a `code`. The key never appears in an error or a log line.

| code | Meaning |
| --- | --- |
| `item_errors` | The API refused some items. `itemErrors` lists `{ index, code, field, message, insertId, event }`. Never retried. |
| `request_rejected` | The API refused the whole request, for example `invalid_key`. `apiCode` and `status` say why. Never retried. |
| `request_failed` | Every retry failed; `count` items were lost. |
| `queue_full` | `maxQueueSize` was reached and a new item was dropped. `dropped` is the running count. |
| `shutdown_timeout` | `shutdown()` hit its deadline; `count` items weren’t delivered. |
| `invalid_call` | A `track` or `identify` call was malformed; nothing was queued. |
| `item_too_large` | One item serialised to more than 1 MiB. |
| `client_closed` | A call arrived after `shutdown()`. |

```ts title="clickclacks.ts"
const clickclacks = new ClickClacks({
  key: process.env.CLICKCLACKS_SERVER_KEY!,
  onError(error) {
    // error.code: item_errors, request_rejected, request_failed, queue_full…
    logger.warn({ code: error.code, count: error.count, apiCode: error.apiCode }, error.message)
    for (const item of error.itemErrors ?? [])
      logger.warn({ insertId: item.insertId, code: item.code, field: item.field }, 'ClickClacks refused an item')
  },
})
```

## Testing {#testing}

There’s no special test mode; you don’t need one. Pass your own `fetch` to capture requests instead of sending them, turn the timer off, and `await flush()` when you want to assert. Bodies over 1 KiB arrive gzipped, so keep test events small or decompress them.

```ts title="invoice.test.ts"
import { ClickClacks } from '@clickclacks/node'
import { expect, test } from 'vitest'

test('tracks a paid invoice', async () => {
  const bodies: unknown[] = []
  const clickclacks = new ClickClacks({
    key: 'cks_live_test',
    flushInterval: 0,
    fetch: async (_url, init) => {
      bodies.push(JSON.parse(String(init.body)))
      return { status: 202, headers: { get: () => null }, text: async () => '{"accepted":1,"dropped":[],"errors":[]}' }
    },
  })

  clickclacks.track({ event: 'Invoice paid', distinctId: 'user_8412' })
  await clickclacks.flush()

  expect(bodies).toMatchObject([{ items: [{ event: 'Invoice paid', distinct_id: 'user_8412' }] }])
})
```

To check real payloads against the API without storing anything, send a batch to [`?validate=true`](https://clickclacks.io/docs/api.md#validate) with any HTTP client.

## Groups {#groups}

> **Coming soon**
>
> Group analytics (counting companies or teams, not just people) isn’t generally available yet. Version 1.1.0 of the SDK has a `group()` call for it. The API already accepts and stores group items and group membership from every project; the Companies screens are rolling out project by project, and your data is there when they reach you. Membership on an event looks like `properties: { $groups: { company: 'cmp_311' } }`.

## Next steps {#next}

- [Send events](https://clickclacks.io/docs/api.md): the API the SDK speaks, including every error code.
- [Identity from the server](https://clickclacks.io/docs/server-identity.md): joining server events to browser history.
- [@clickclacks/node on npm](https://www.npmjs.com/package/@clickclacks/node).
