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_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

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.

Quick start

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:

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

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

OptionDefaultWhat it does
keyrequiredA secret server key, cks_live_… (older sk_live_… keys also work). A browser pk_live_ key throws.
hosthttps://app.clickclacks.ioWhere to send. Custom tracker domains don’t serve the server API, so leave it unless told otherwise.
flushAt100Items per batch, 1–500. Reaching it sends at once.
flushInterval5000Milliseconds between automatic flushes. 0 turns the timer off, for Workers and serverless.
maxQueueSize10000Items held in memory, queued or in flight. When full, new items are dropped.
maxRetries6Retries after the first attempt.
requestTimeout10000Milliseconds before a request is abandoned and retried.
onErrorconsole.warnReceives a ClickClacksError. See Errors.
fetchglobal fetchReplace it for proxies or tests.

track

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 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.
  • Fields are camelCase here and snake_case on the wire (distinctId → distinct_id).

identify

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

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.
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 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.

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

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.

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

  • 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, 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

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.

codeMeaning
item_errorsThe API refused some items. itemErrors lists { index, code, field, message, insertId, event }. Never retried.
request_rejectedThe API refused the whole request, for example invalid_key. apiCode and status say why. Never retried.
request_failedEvery retry failed; count items were lost.
queue_fullmaxQueueSize was reached and a new item was dropped. dropped is the running count.
shutdown_timeoutshutdown() hit its deadline; count items weren’t delivered.
invalid_callA track or identify call was malformed; nothing was queued.
item_too_largeOne item serialised to more than 1 MiB.
client_closedA call arrived after shutdown().
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

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.

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 with any HTTP client.

Groups

Next steps