The header names, algorithm, batch size, and retry behavior in this article are taken directly from the adapter that sends the requests — they're the shipping contract, not a paraphrase. Where a recommendation is generic receiver hygiene rather than something ClickStream enforces (body-hash dedupe, dead-letter queues), the article says so.
The Signed Webhook Contract
ClickStream's CRM export pushes identified visitors out to five destinations — HubSpot, Salesforce, Pipedrive, Zoho, and the one this article is about: a generic webhook, meaning any HTTPS endpoint you operate. You configure two values in the per-site integrations panel, a url and a secret, and from then on contact records arrive as signed POSTs. (For how a visitor becomes an exportable contact in the first place, see Promoting Anonymous Visitors to CRM Contacts.)
On the wire, a delivery looks like this:
POST /hooks/clickstream HTTP/1.1
Host: your-endpoint.example.com
Content-Type: application/json
X-ClickStream-Timestamp: 1785196800000
X-ClickStream-Signature: sha256=4a1f0b9c…e37d
{"records":[{"email":"…","clickstreamId":"…"}]}
Three facts define the contract:
X-ClickStream-Signatureis the stringsha256=followed by a lowercase hex HMAC-SHA256 of the exact raw request body, keyed with your shared secret.X-ClickStream-Timestampis the Unix time in milliseconds at which the request was signed.- The body is a JSON object with a single
recordsarray — up to 25 contact records per batch.
Each record carries a required email (records without a plaintext email are skipped upstream, because CRMs dedupe on email), optional firstName, lastName, company, and phone, the visitor's stable clickstreamId, and an optional properties map for the opt-in extras — behavioral properties like visit and session counts and first/last-seen when you enable them, and the clickstream_* identity fields only when you've both opted in and accepted the data-processing addendum. Sensitive enrichment fields never appear in a webhook record, and visitors flagged do_not_sell or processing_restricted are excluded before a record is ever built.
One more detail worth knowing before you write a single line: when you first save the integration, ClickStream proves the connection with a signed empty batch — literally {"records":[]}. Your handler has to verify it and return a 2xx like any other delivery. If your endpoint rejects empty arrays as malformed, the connection test fails before any real data flows.
Webhook Signature Verification, Step by Step
Verification is three rules. Every webhook horror story maps to skipping one of them.
1. Verify the Bytes You Received, Not the JSON You Parsed
The HMAC covers the exact body ClickStream sent. If your framework parses the JSON and you re-serialize it to compute the digest, whitespace and key order can silently change — and a perfectly valid delivery fails verification. Capture the raw body before any JSON middleware touches it, compute HMAC-SHA256 over those bytes, and only parse after the signature checks out.
2. Compare in Constant Time
A naive string comparison (===, ==) short-circuits at the first mismatched character, and that timing difference is measurable enough to leak the expected digest byte by byte. Every mainstream runtime ships a constant-time comparison — crypto.timingSafeEqual in Node, hmac.compare_digest in Python. Use it, always.
3. Fail Closed
Page-side ClickStream code is deliberately fail-open — when a score read fails, the worst case is your default page. A webhook receiver is the opposite case: the failure mode of skipping verification is accepting forged contact data into your systems from anyone who finds the URL. Bad or missing signature means reject with a 401 and process nothing. Usefully, ClickStream treats a 4xx as a permanent failure — the batch is marked failed and not retried — so rejecting forgeries never triggers a retry storm against your endpoint.
A Node Receiver
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.CLICKSTREAM_WEBHOOK_SECRET;
// The signature covers the exact bytes on the wire — mount a
// raw-body parser on this route so nothing re-serializes first.
app.post(
'/hooks/clickstream',
express.raw({ type: 'application/json' }),
(req, res) => {
const provided = req.get('X-ClickStream-Signature') ?? '';
const expected = 'sha256=' +
crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end(); // permanent — ClickStream won't retry a 4xx
}
const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex');
const { records } = JSON.parse(req.body.toString('utf8'));
enqueue({ bodyHash, records }); // ack now, process off the request path
res.status(200).end();
},
);
A Python Receiver
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["CLICKSTREAM_WEBHOOK_SECRET"].encode()
@app.post("/hooks/clickstream")
def clickstream_webhook():
raw = request.get_data() # exact raw bytes, before any JSON parsing
provided = request.headers.get("X-ClickStream-Signature", "")
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(provided, expected):
abort(401) # permanent — ClickStream won't retry a 4xx
records = request.get_json()["records"] # [] on the connection test
enqueue(hashlib.sha256(raw).hexdigest(), records)
return "", 200
The Timestamp Header, Honestly
Here's a detail most webhook write-ups gloss over, and this one won't: in ClickStream's scheme the HMAC covers the body, and the timestamp travels as a header. That means X-ClickStream-Timestamp tells you when ClickStream signed the batch — genuinely useful for delivery-latency monitoring, ordering diagnostics, and a coarse staleness alarm — but a freshness check on it is not a cryptographic replay boundary, because the header isn't bound into the signature the way the body is.
So don't build your replay defense out of clock math. Build it out of the thing the signature actually guarantees — this exact body was produced by a holder of the secret — plus processing that doesn't care how many times that body arrives. Which brings us to the part that matters most.
Idempotent Processing Is Your Real Replay Defense
The payload shape is doing you a favor: it's an upsert of contact records, keyed on email and carrying a stable clickstreamId. If your handler upserts too — insert-or-update on email or clickstreamId, never blind-insert — then applying the same batch twice converges to the same state instead of double-applying. A replayed delivery becomes a no-op by construction.
Duplicates aren't hypothetical, and you don't need an attacker to see one. ClickStream retries deliveries that fail with a 429, a 5xx, or a transport error: up to three attempts total, with 400 ms and 1.2 s delays and a 15-second timeout per attempt. If your handler did its work but crashed — or dawdled past the timeout — before returning the 2xx, the retry that follows is a duplicate of work already done. Idempotency is what makes that sequence boring instead of a data-corruption incident.
For an extra belt to go with those suspenders: the contract has no delivery-ID header, but every delivery arrives with a natural fingerprint — the raw body itself. Hash it (both receivers above already compute bodyHash), keep a short-lived set of recently seen hashes, and skip a body you've already processed. This is receiver-side hygiene, not something ClickStream requires — but it turns "at-least-once delivery" into "effectively-once processing" with about five lines of code.
Ack Fast, Process Async, Dead-Letter the Rest
Two contract facts should shape your handler's architecture:
- Each attempt has a 15-second budget. Blow it and the attempt is aborted and counted as a failure — and a retry (with its duplicate-processing risk) may follow.
- Any 2xx acknowledges the entire batch; anything else fails the entire batch. There is no per-record status channel in the response.
The pattern that fits both: verify the signature, persist the raw body to a queue, return 200, and do the real work — CRM-side writes, enrichment of your own records, notifications — off the request path. Never make your 2xx wait on a downstream system.
The all-or-nothing acknowledgment also decides where failures live. If record 14 of 25 fails validation in your system, returning a 5xx would force ClickStream to retry all 25 — including the 24 that succeeded. The better shape is a dead-letter queue on your side (again: generic best practice, your infrastructure): accept the batch, process what you can, and park each failing record with its error and the batch's bodyHash for scheduled retry and alerting on queue depth. Reserve non-2xx responses for systemic conditions where a retry can actually help — your database is down, you're overloaded (return 429; it's in the retryable set). On ClickStream's side, a batch that still fails after retries is recorded per record with a normalized error string that never contains credential material.
Endpoint Requirements and Secret Hygiene
ClickStream's egress guard constrains where webhooks can be sent, and it's stricter than most: HTTPS only, to a public host. Plain http://, localhost, private and link-local IP ranges, and internal-looking hostnames are all refused — and a hostname that can't be positively resolved to a public address fails closed. This is deliberate SSRF protection (a user-supplied URL must not become a proxy into anyone's internal network), and its practical consequence for you is that local development needs a public HTTPS tunnel; you cannot point the destination at your laptop directly.
On the secret itself: in ClickStream it's write-only — accepted when you save, AES-256-GCM encrypted at rest with a per-site key, and never echoed back to any client, the same posture described in Your Data, Your Key. Match that posture on your side: environment variable or secret manager, never source code — the same discipline that applies to API keys. When you rotate, update both ends together; deliveries signed with the old secret during the gap will fail verification and surface as failed batches, and a manual export run from the dashboard can re-send the eligible set once the new secret is live on both sides.
Availability follows the CRM export gates: manual export runs are Growth and above, and automatic incremental sync is Scale and above with per-plan monthly caps. And if what you actually want pushed to your servers is realtime behavioral data rather than contact records, that's a different pipe — the read-only Signals Feed, covered in High-Intent Alerts in Slack via the Signals Feed.
The Bottom Line
- Recompute, then compare. HMAC-SHA256 of the exact raw body with your shared secret, prefixed
sha256=, againstX-ClickStream-Signature— constant-time, fail closed with a 401 (which is never retried). - Never verify re-serialized JSON. Raw-body middleware first;
JSON.parseafter the signature passes. - Ack in seconds, work in the background. 15-second budget per attempt, and a 2xx acknowledges all 25 records at once.
- Design for duplicates. Upsert on
email/clickstreamId, dedupe on the body hash — that's your replay protection, not timestamp math. - Expect
{"records":[]}. The connection test is a signed empty batch; verify it and say 200.
A webhook endpoint is an API with exactly one legitimate caller. Verify the caller cryptographically, then build as if every delivery can arrive twice — because one day it will.