Engineering

Server-Side Event Tracking: Beyond the Browser

The Server property type gives your backend its own key family and a relaxed page contract on the same /v1/events endpoint the pixel uses — so payment truth, subscription changes, and webhook-driven state land on the visitor timeline your browser events already built.

July 2026 • 9 min read • REST ingestion — no SDK required

There is no Node server SDK package, and that's deliberate: the REST surface is the server integration. Everything below — endpoint, auth, payload fields, limits, response shapes — is checkable against ClickStream's published event schema and ingestion route. If a field isn't here, don't send it.

Money Truth Doesn't Live in the Browser

The browser SDK is the right tool for behavior. Pageviews, clicks, scroll depth, form struggle — those signals only exist where there's a viewport and a DOM, and ClickStream's 344-byte loader captures them well. But a surprising number of teams also fire their most important events from the browser: payment_succeeded on the thank-you page, subscription_upgraded after the modal closes.

That's server-side event tracking done backwards. A conversion event fired from a thank-you page is a hope — the tab can close before the script runs, a blocker can eat the request, and the page has no idea whether the charge actually settled. The systems that know — your billing service, your payment provider's webhook handler, your entitlement logic — live behind your API, where there is no pixel. The fix isn't to make the browser more reliable. It's to send money truth from the place that owns it, and behavior from the place that observes it.

The Server Property Type and the cs_srv_live Key

ClickStream has three property types — Website, Mobile app, and Server — and the type decides which key family you get. Creating a Server property in the dashboard (no URL, no DNS) mints a dedicated cs_srv_live_* key. What makes it different from your website key is provenance: website keys are domain-gated — the collector requires a browser Origin/Referer matching your configured domains and rejects mismatches with 403 domain_not_allowed. Server keys skip that gate entirely, which is exactly what lets a backend POST /v1/events with no Origin header and no fabricated web URL. The full gating model is covered in API Keys and Domain Gating.

Two verified behaviors follow from key posture:

Key posture also changes bot semantics. A library user agent like acme-backend/1.0 under a server key is expected traffic, exempt from the browser UA-scraper heuristics. The same UA on a website key forces a bot classification.

The Ingestion Contract, Verified

Server events go to the same endpoint as everything else — POST /v1/events on your registered first-party tracking domain — authenticated with the X-API-Key header (a ?key= query param exists for the browser's sendBeacon; backends should use the header). Because clientPlatform is 'server', the page schema relaxes: page.url becomes optional and page.path is a free-form logical route, not a pathname.

POST https://t.example.com/v1/events HTTP/1.1
X-API-Key: cs_srv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "type": "custom",
  "visitorId": "cs_visitor_abc",
  "sessionId": "cs_session_xyz",
  "timestamp": 1721145600000,
  "name": "subscription_renewed",
  "category": "billing",
  "value": 199,
  "page": { "path": "billing/renewal", "title": "Renewal" },
  "device": {
    "userAgent": "acme-backend/1.0",
    "viewport": { "width": 0, "height": 0 },
    "clientPlatform": "server"
  }
}

The parts of the contract worth memorizing:

Tying Backend Truth to the Visitor Your Pixel Already Knows

The whole point of sending server events to ClickStream rather than a log file is identity: visitorId is the join key, and your backend already has it. The browser SDK persists the visitor id as the _cs_vid first-party cookie on your domain — server-reinforced with a 400-day max-age, aligned with Chrome's hard cap — so every request the visitor's browser makes to your backend carries it in the Cookie header. The session id rides along as _cs_sid, stored as sid|timestamp; split on the pipe.

The subtlety is that webhooks don't carry cookies. Your payment provider calls you from its infrastructure, not from the visitor's browser. So the reliable pattern is a two-step join:

Step 1: capture the visitor id when the browser touches your backend

// Express-style checkout endpoint — the browser sends this request,
// so the ClickStream cookies are on it.
function readCookie(header, name) {
  for (const part of (header || '').split(';')) {
    const eq = part.indexOf('=');
    if (eq > 0 && part.slice(0, eq).trim() === name) {
      return decodeURIComponent(part.slice(eq + 1).trim());
    }
  }
  return null;
}

app.post('/api/checkout', async (req, res) => {
  const order = await createOrder(req.body);

  // _cs_vid is the visitor id; _cs_uid (the clickstream id) is the fallback.
  const visitorId = readCookie(req.headers.cookie, '_cs_vid')
    || readCookie(req.headers.cookie, '_cs_uid');
  const sessionId = (readCookie(req.headers.cookie, '_cs_sid') || '').split('|')[0];

  await saveTrackingContext(order.id, { visitorId, sessionId });
  res.json({ orderId: order.id });
});

Step 2: join it when the webhook lands

app.post('/webhooks/payments', async (req, res) => {
  // 1. Verify the provider's signature; update your own database first.
  const delivery = await verifyAndParse(req);
  const ctx = await getTrackingContext(delivery.orderId);
  if (!ctx || !ctx.visitorId) return res.sendStatus(200); // no join, no event

  // 2. Build the payload ONCE — retries must resend identical bytes.
  const event = {
    type: 'custom',
    visitorId: ctx.visitorId,
    sessionId: ctx.sessionId || 'srv_' + delivery.orderId,
    timestamp: Date.now(),
    name: 'payment_succeeded',
    category: 'billing',
    label: delivery.orderId,
    value: delivery.amount,
    page: { path: 'billing/payment', title: 'Payment' },
    device: {
      userAgent: 'acme-backend/1.0',
      viewport: { width: 0, height: 0 },
      clientPlatform: 'server',
    },
  };

  await sendClickStreamEvent(event); // retry helper below
  res.sendStatus(200);
});

Reusing the captured sessionId ties the payment to the session where checkout actually happened. If the webhook arrives hours later and you never captured one, any stable string within the 128-character limit is valid — the visitor id is what stitches identity.

Retries and Idempotency, Honestly

The endpoint has no idempotency-key header, so it's worth being precise about what protects you. The collector deduplicates retried events by signature: visitorId + sessionId + the timestamp rounded to the second + event type + page URL, held for a 30-second window sized for retry bursts. Two consequences for your retry logic:

async function sendClickStreamEvent(event, attempts = 3) {
  const body = JSON.stringify(event); // serialize ONCE
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fetch('https://t.example.com/v1/events', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': process.env.CLICKSTREAM_SERVER_KEY, // cs_srv_live_*
        },
        body,
      });
      if (res.status === 202) return true;
      if (res.status === 400) {
        console.error('invalid event', await res.json()); // details[] names the field
        return false; // retrying can't fix validation
      }
    } catch (err) { /* network — fall through to retry */ }
    await new Promise((r) => setTimeout(r, 500 * 2 ** i));
  }
  return false;
}

What Belongs Server-Side vs. Client-Side

SignalSend fromWhy
Pageviews, clicks, scroll, formsBrowser SDKBehavior needs a viewport, a DOM, and timing — and it feeds the behavioral scores
Payment succeeded / failedServerOnly the webhook knows whether the charge settled
Subscription created / changed / canceledServerBilling state changes off-session (renewals, dunning)
Refunds, chargebacksServerNo browser is present at all
Plan / entitlement changesServerYour backend is the system of record
Email verified, account stateServerConfirmed out-of-band, not in the session

The rule compresses to one sentence: behavior client-side, truth server-side. If an event involves money or account state and the browser could miss it or lie about it, it belongs behind your API with a cs_srv_live_* key.

Server-Side Identify: Hashes, Not Emails

Backends also hold the cleanest identity data, and the identify event type accepts server senders. The contract is hash-first: lowercase and trim the email, SHA-256 it, and send the hex digest as hem. The field is format-validated at the API boundary — 64 hexadecimal characters or a 400 — so a raw email pasted into hem is rejected, never silently stored. The identity object carries your opaque keys alongside it: customerId, accountId, userId, crmContactId, orderId, each capped at 128 characters.

{
  "type": "identify",
  "visitorId": "cs_visitor_abc",
  "sessionId": "cs_session_xyz",
  "timestamp": 1721145600000,
  "hem": "b4c9a2… (SHA-256 of lowercased, trimmed email — 64 hex chars)",
  "identity": { "customerId": "cus_1234", "orderId": "ord_5678" },
  "page": { "path": "billing/upgrade", "title": "Upgrade" },
  "device": {
    "userAgent": "acme-backend/1.0",
    "viewport": { "width": 0, "height": 0 },
    "clientPlatform": "server"
  }
}

This is the server-side half of the promotion story told in Promoting Anonymous Visitors to CRM Contacts: the pixel built an anonymous behavioral history under _cs_vid, and one identify from your backend — at signup, at purchase, wherever your system learns who the visitor is — stitches your customer record to it.

From Server Event to Score

Accepted server events don't land in a separate silo. They join the same per-visitor timeline that ClickStream's behavioral models score, under the same identity graph. The custom event's value field is the documented numeric input for customer-defined scoring, and per-site scoring weights let you decide how much a renewal or an upgrade should move a visitor relative to browsing behavior.

The loop closes on the read side. Any backend can fetch the scored snapshot for a visitor — GET /v1/signals/:visitorId, roughly 50–150 ms round trip — and get the same fields page code sees via the Signals API: the eleven-field score snapshot (intent through sessionMomentum and confusion, plus emotionalState and decisionStage) and the bot verdict. That read pattern gets its own article: Server-Side Visitor Context from Any Backend. And the standing guardrail applies to backends exactly as it does to page code: use scores to help visitors, never to score-gate pricing — every human sees the same price.

One direction note to avoid a common mix-up: /v1/events is you writing to ClickStream. Data flowing back out — the CRM webhook destination pushing identified visitors into your systems — is a different, signed surface, covered in Verifying Signed Webhooks.

The Bottom Line

A conversion event fired from a thank-you page is a hope. The same event fired from your webhook handler is a fact. Send facts.

Send Your First Server Event

Create a Server property, mint a cs_srv_live key, and POST one payment event from the system that actually knows it happened. Same visitor, same timeline, no browser required.

Start free