# Server-side tracking with Node

> A step-by-step walkthrough of @clickclacks/node: create a server source and a cks_live_ key, track and identify from an Express API and a retried background job, flush in serverless functions, check events arrive, and join them to the browser person.

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

Acme CRM’s API knows things no browser sees for certain: a workspace was created, a contact import finished. This walkthrough sends those events from Node with @clickclacks/node, and lands them on the same people as the browser events.

## The goal {#goal}

By the end, Acme’s Express API sends `Workspace created`, an import job sends `Contacts imported`, a serverless function sends `Report exported`, and you can open one customer in People and see their browser and server events on one timeline.

You need Node 18 or later (or Cloudflare Workers) and a project with a browser source already sending events. If you haven’t done that, start with [Track your first custom events](https://clickclacks.io/docs/walkthroughs/first-events.md).

## 1. Add a server source {#source}

Server events get their own source, next to your website and web app, in the same project. That is what lets one funnel run from a pricing page to `Invoice paid`.

1. Open **Sources** and click **Add source**.
2. Choose **Server**: a secret key and the Node SDK or HTTP API.
3. Name it after the system that sends, such as _API_, and click **Create server source**.

> **What you should see**
>
> The server source’s page, which doubles as its install page, with the status _Waiting for the first event_. The dialog to create its first key opens by itself.

## 2. Create and store a key {#key}

1. Name the key, for example _Production_, and create it.
2. Copy it from **Copy your server key**. It starts `cks_live_` and is shown once; afterwards the app only shows its first characters and last four.
3. Store it as a secret in the environment your API runs in, as `CLICKCLACKS_SERVER_KEY`.

```bash title=".env"
# .env (never committed)
CLICKCLACKS_SERVER_KEY=cks_live_example
```

Keys live on the server source’s page, under **Secret keys**. A source can have two working keys at once, so you can roll one later without dropping events. See [Server keys](https://clickclacks.io/docs/server-side.md#keys).

> **Server keys never go to a browser**
>
> The API refuses any request that carries browser headers (`403 browser_not_allowed`), and the source’s health panel warns you if it happens. Keep the key out of client-side code, mobile apps, repositories and logs.

## 3. Install the SDK {#install}

```bash title="terminal"
npm install @clickclacks/node
```

It has no runtime dependencies and ships ESM, CommonJS and TypeScript types. It batches, gzips large bodies and retries safely, so your code just calls `track`.

## 4. Create one client {#client}

Create a single client per process and import it wherever you track:

```ts title="analytics.ts"
// analytics.ts: one client for the whole process
import { ClickClacks } from '@clickclacks/node'

export const clickclacks = new ClickClacks({
  key: process.env.CLICKCLACKS_SERVER_KEY!,
  onError(error) {
    // Calls never throw; delivery problems arrive here instead.
    console.warn('[clickclacks]', error.code, error.message)
  },
})
```

- The constructor throws if the key is missing, or if you pass a browser `pk_live_` key by mistake. That is the only place the SDK throws.
- By default a batch goes out every 5 seconds or when 100 items are waiting (`flushInterval` and `flushAt`). Every option is in the [Node SDK reference](https://clickclacks.io/docs/node.md#options).

## 5. Track from a route {#track}

Track after the work has succeeded, with the signed-in user’s ID as `distinctId`. Use exactly the ID the browser passes to `identify`; that is how the two sides meet.

```ts title="routes/workspaces.ts"
// routes/workspaces.ts
import express from 'express'
import { clickclacks } from '../analytics'

export const workspaces = express.Router()

workspaces.post('/api/workspaces', requireUser, async (req, res) => {
  const workspace = await db.workspaces.create({
    ownerId: req.user.id,
    name: req.body.name,
    template: req.body.template ?? 'blank',
  })

  clickclacks.track({
    event: 'Workspace created',
    distinctId: String(req.user.id),          // the same ID the browser passes to identify
    insertId: `ws_${workspace.id}_created`, // a retry of this request still counts once
    properties: { template: workspace.template },
  })

  res.status(201).json(workspace)             // track() returned at once; sending happens in the background
})
```

- `distinctId` must be a string of 1 to 128 characters. If your IDs are numbers, convert them, as above.
- `insertId` is optional; the SDK generates one for every item. Pass your own when the same thing could be tracked twice, such as a retried request.
- No `sessionId` means the event isn’t part of any browser session, so it never changes session counts.

## 6. Track a background job {#job}

Acme imports contacts in a queue job, which the queue may run twice after a failure. Derive the `insertId` from the job, and the API stores a repeat once, however many times it arrives:

```ts title="jobs/import-contacts.ts"
// jobs/import-contacts.ts: a queue job that may be retried
export async function importContacts(job: ImportJob) {
  const result = await importCsv(job.fileId, job.workspaceId)

  clickclacks.track({
    event: 'Contacts imported',
    distinctId: String(job.userId),
    insertId: `import_${job.id}`,           // a retried job still counts once
    timestamp: result.finishedAt,          // when it happened, not when the job was picked up
    properties: { count: result.count, source: 'csv' },
  })
}
```

- `timestamp` can be a `Date`, an ISO string or epoch milliseconds. It defaults to the moment you call `track`. The API accepts 7 days back to 10 minutes ahead; older items are refused.
- Revenue works the same way: add `$revenue` and `$currency` (three upper-case letters) to the properties of a payment event. Acme sends `Invoice paid` from its Laravel billing service, in the [next walkthrough](https://clickclacks.io/docs/walkthroughs/laravel.md).

## 7. Identify from the server {#identify}

Your database knows a customer’s plan and seat count for certain, so it is often the best place to set traits. A person’s traits come from their newest identify, whichever side sent it, and identify calls are free.

```ts title="routes/billing.ts"
// When the plan changes, record the person's current traits.
clickclacks.identify({
  distinctId: String(user.id),
  properties: { plan: 'growth', seats: 5, role: 'owner' },
})
```

## 8. Flush before exit {#shutdown}

The flush timer doesn’t keep a Node process alive, so when your server stops, deliver what’s still queued. `shutdown()` flushes, stops the timer and refuses new calls:

```ts title="server.ts"
// server.ts
import { app } from './app'
import { clickclacks } from './analytics'

const server = app.listen(3000)

process.on('SIGTERM', () => {
  server.close(async () => {
    await clickclacks.shutdown() // delivers what's queued, waits up to 10 seconds
    process.exit(0)
  })
})
```

For a script, cron job or queue worker, call `await clickclacks.shutdown()` at the end of the run.

## 9. Serverless and Workers {#serverless}

A serverless runtime can freeze the moment your handler returns, taking queued events with it. Turn the timer off with `flushInterval: 0` and flush yourself:

```ts title="app/api/reports/route.ts"
// A Next.js route handler, a Vercel or Netlify function, or AWS Lambda
import { ClickClacks } from '@clickclacks/node'

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

export async function POST(request: Request) {
  const user = await currentUser(request)
  const report = await exportReport(user)

  clickclacks.track({ event: 'Report exported', distinctId: String(user.id), properties: { format: report.format } })
  await clickclacks.flush() // before returning: the runtime may freeze afterwards

  return Response.json(report)
}
```

On Cloudflare Workers (no `nodejs_compat` flag needed), let the response go first and finish delivery in the background:

```ts title="worker.ts"
// Cloudflare Workers: flush after the response with waitUntil
clickclacks ??= new ClickClacks({ key: env.CLICKCLACKS_SERVER_KEY, flushInterval: 0 })
clickclacks.track({ event: 'Report exported', distinctId: userId })
clickclacks.flushWith(ctx) // ctx.waitUntil(clickclacks.flush())
```

`flushWith` takes any object with a `waitUntil` method, so it works on other platforms that offer one too. See [Cloudflare Workers](https://clickclacks.io/docs/node.md#workers) and [Serverless functions](https://clickclacks.io/docs/node.md#serverless).

## 10. Check it arrived {#verify}

1. Deploy, then create a workspace in the app with a test account.
2. Open the server source’s page in **Sources**.
3. Open **Events**, pick `Workspace created`, set the source to _API_ in the filter bar, and expand the newest row.

> **What you should see**
>
> The status light reads _Receiving · last event 4s ago_ (or similar). The **Server API health** card shows the last event received, events and rejections in the last 24 hours, and which SDK is calling. In Events, the row shows your `template` property under **Your properties**.

Want to check a payload before you deploy? Send it to the API as a dry run with `?validate=true`: you get each item back exactly as it would be stored, with every error, and nothing is stored or counted.

```bash title="terminal"
curl "https://app.clickclacks.io/api/v1/batch?validate=true" \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"event":"Workspace created","distinct_id":"user_4821","properties":{"template":"blank"}}]}'
```

## 11. Find the person {#person}

1. Open **People** and search for your test user’s ID.
2. Open their profile and read **Activities**.

> **What you should see**
>
> One person, with the browser’s `Signed up` and pageviews and the server’s `Workspace created` on the same timeline. The **Traits** rail shows the plan and seats from your server-side identify.

If the server events sit on a separate person, the IDs don’t match. The [identity walkthrough](https://clickclacks.io/docs/walkthroughs/one-person.md) covers why, and how to fix it.

## Common mistakes {#mistakes}

- **A different ID on each side.** The browser identifies with `user_4821` and the server sends `4821`, or an email. They become two people. Use one stable ID everywhere.
- **A new client per request.** Each one keeps its own queue and timer. Create one per process and reuse it.
- **Not flushing in serverless.** Events queued when the function freezes are lost. Use `flushInterval: 0` and `await flush()`, or `flushWith`.
- **Sending one event per request by hand.** Rate limits count events in blocks of 100 per request, so single-event requests run out far sooner. The SDK batches for you.
- **The same event from the browser and the server.** It counts twice. Pick one place for each event.
- **Pointing `host` at your custom tracker domain.** Custom domains carry browser traffic only. Leave `host` at its default.
- **Ignoring `onError`.** Refused items (`item_errors`) are never retried. Log them so a bad property doesn’t go unnoticed.

## Next {#next}

Acme’s billing service is a Laravel app. For the PHP side, continue with [Server-side tracking with Laravel](https://clickclacks.io/docs/walkthroughs/laravel.md), or skip to [One person across browser and backend](https://clickclacks.io/docs/walkthroughs/one-person.md).

## Related {#related}

- [Node.js SDK](https://clickclacks.io/docs/node.md): Options, delivery, errors and testing.
- [Server-side tracking](https://clickclacks.io/docs/server-side.md): Why server-side, and how keys work.
- [Send events](https://clickclacks.io/docs/api.md): The API the SDK speaks, with every error code.
- [Identity from the server](https://clickclacks.io/docs/server-identity.md): Which person each event lands on.
