Node.js SDK
@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.
Updated
- Node 18 or later, and Cloudflare Workers (no
nodejs_compatflag 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
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.
Quick start
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:
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
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. |
fetch | global fetch | Replace it for proxies or tests. |
track
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) oranonymousId(a browser’sper_…ID) is required. Identity from the server explains which person each lands on. timestampdefaults to the moment you calltrack. The API accepts 7 days back to 10 minutes ahead.- In
propertiesyou may send$ip,$user_agent,$country,$current_url,$groups,$revenueand$currency; other$keys are refused by the API. See Properties. - Fields are camelCase here and snake_case on the wire (
distinctId→distinct_id).
identify
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
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. Aftertimeoutmilliseconds it stops retrying and reports what’s left asshutdown_timeout. Safe to call twice. Use it onSIGTERM, 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 toonError.
// 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 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.
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
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.
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
- Batching. A batch goes out when the queue reaches
flushAtor afterflushInterval, 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, 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_idwhen 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,429and5xxare retried with exponential backoff (500 ms × 2ⁿ, capped at 30 seconds) and full jitter. ARetry-Afterfrom the API always wins, up to 5 minutes. A413splits the batch in half and resends. Other4xxanswers 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
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(). |
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
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.
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 with any HTTP client.
Groups
Next steps
- Send events: the API the SDK speaks, including every error code.
- Identity from the server: joining server events to browser history.
- @clickclacks/node on npm.