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:
- The key is a platform declaration. Events sent under a server key that don't declare
device.clientPlatformget'server'stamped before validation — so a plaincurlworks. An explicit declaration always wins. - You can't fake it with a website key. On a browser key, platform is server-authoritative: a client-declared
"clientPlatform": "server"is stripped before validation, and the event falls under the strict web contract (a realhttp(s)page.url, browser bot heuristics). Don't reuse a website key off the web — create the matching property.
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:
- Batching: one event per request, or up to 25 in a single
{ "events": [...] }wrapper. Batches are partially accepted — valid events proceed, invalid ones come back indexed in the error list. - Validation: a Zod schema runs server-side. Failures return
400 validation_errorwith adetailsarray naming the failing field path and message — log it; retrying an invalid payload will never succeed. - Success is
202:{ "success": true, "accepted": 1, "received": 1, "timestamp": ... }. Accepted-for-async-processing, not synchronous persistence. - Timestamps are milliseconds since epoch; the server overrides values skewed more than five minutes.
- Custom event fields are exactly five:
name(required, ≤256 chars),category,action,label(≤4 KB), and a numericvalue.
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:
- Build once, retry identical bytes. A retry that re-stamps
Date.now()has a different signature — it's a new event, and you just double-counted a payment. Serialize the payload once and resend exactly that. - The window is 30 seconds, not forever. Webhook redeliveries — your provider retrying you hours later — are outside it. Idempotency at that horizon is yours: keep a processed-deliveries ledger keyed on the provider's delivery id, and skip deliveries you've already handled before emitting anything.
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
| Signal | Send from | Why |
|---|---|---|
| Pageviews, clicks, scroll, forms | Browser SDK | Behavior needs a viewport, a DOM, and timing — and it feeds the behavioral scores |
| Payment succeeded / failed | Server | Only the webhook knows whether the charge settled |
| Subscription created / changed / canceled | Server | Billing state changes off-session (renewals, dunning) |
| Refunds, chargebacks | Server | No browser is present at all |
| Plan / entitlement changes | Server | Your backend is the system of record |
| Email verified, account state | Server | Confirmed 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
- Create a Server property — it mints the
cs_srv_live_*key that skips the Origin gate. Never reuse a website key server-side; the collector strips the platform declaration and holds you to the web contract. - The contract is small:
POST /v1/events,X-API-Key, envelope + type fields, batches of up to 25,202on accept,400with named fields on validation failure. - Capture
_cs_vidwhen the browser touches your backend, store it on the order, and join it when the webhook lands — webhooks themselves carry no cookies. - Retry identical bytes inside the collector's 30-second dedup window; keep your own delivery ledger for provider redeliveries beyond it.
- Behavior client-side, truth server-side — and identity as hashes, never raw emails.
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.