Server-side tracking with Node

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.

Updated

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

1. Add a server 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.

2. Create and store a 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.
.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.

3. Install the SDK

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

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

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.

5. Track from a route

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.

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

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:

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.

7. Identify from the server

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.

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

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:

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

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:

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:

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 and Serverless functions.

10. Check it arrived

  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.

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.

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

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

If the server events sit on a separate person, the IDs don’t match. The identity walkthrough covers why, and how to fix it.

Common 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

Acme’s billing service is a Laravel app. For the PHP side, continue with Server-side tracking with Laravel, or skip to One person across browser and backend.