Server-side event ingestion — events your client SDK can't fire
A meaningful share of every customer's timeline can only be reconstructed from the server. Stripe payment confirmations, ESP click redirects, cron-driven renewals, admin overrides, SSR-rendered pages — none of these run inside the browser, so the web SDK never sees them. This doc covers when to fire from the server, how to do it through @cross-deck/node, when raw HTTP is the right call, and — most importantly — how server-side events link back to the same crossdeckCustomerId as the events your client SDK is already firing.
TL;DR
- Server-side > client-side whenever the event is authoritative (payment captured, subscription renewed, admin granted a feature) or fires before/without a browser session (cron jobs, ESP redirects, webhook receivers, SSR'd routes).
- Use the Node SDK by default.
new CrossdeckServer({ secretKey })+crossdeck.track(event)gives you a durable queue, batched HTTP, exponential-backoff retries, per-batchIdempotency-Key, and process-exit drain — all without you writing the plumbing. - Raw HTTP works for any backend. Go, Rust, PHP, Ruby, Elixir, or a runtime you don't fully control:
POST /v1/eventswith acd_sk_*secret-key bearer token. You handle batching, retry, and idempotency yourself — strongly prefer the Node SDK if you can. - Idempotency keys are mandatory for any path where the same logical event could fire twice — Stripe webhook replays, retried Lambda handlers, queue redelivery. The Node SDK auto-generates one per batch; raw-HTTP callers should set
eventIdper event and re-use Stripe'sevent.idas the upstream key. - Identity linking is the part nobody gets right by accident. Every server event needs exactly one of
developerUserId/anonymousId/crossdeckCustomerId— and to keep a continuous timeline across client + server, you need to reuse the SAMEanonymousIdthe browser minted (forward it via cookie or header) or callidentify()first to alias the user'suserIdto it. - Flush-on-exit prevents serverless data loss. The Node SDK installs
beforeExit+SIGTERM+SIGINThandlers by default. On Lambda / Cloud Functions cold paths,await crossdeck.flush()explicitly beforereturn— the platform can SIGKILL you before the handlers fire.
The Node SDK reference documents every option, return shape, and error code. This doc shows when you'd reach for those primitives and how the moving parts fit together — webhook receivers, scheduled work, email-click attribution, identity continuity across browser + server. If you're after the literal type signatures, the SDK reference is the source of truth.
When to send events from the server
The default mental model — "fire all events from the client" — falls over the moment the action that matters doesn't happen in a browser tab. Five concrete scenarios you'll hit:
(a) Third-party webhooks — the payment is captured server-side
Stripe fires payment_intent.succeeded to your /webhooks/stripe endpoint after the customer's bank confirms the charge. By the time you hear about it, the customer may have closed the tab, lost network, navigated away, or just be waiting on a redirect. Firing purchase_completed from the client at the moment they click "Pay" is hopeful telemetry — half the time the charge actually fails downstream and you've already counted revenue. Fire it from the webhook receiver instead, after Stripe says the money moved.
// /webhooks/stripe — fired by Stripe, not by the browser
switch (event.type) {
case "payment_intent.succeeded": {
const pi = event.data.object;
crossdeck.track({
name: "purchase_completed",
developerUserId: pi.metadata.userId,
properties: {
amount: pi.amount,
currency: pi.currency,
stripePaymentIntentId: pi.id,
},
// Stripe replays this webhook if our 2xx is slow — same
// event.id ensures Crossdeck dedupes the replay.
eventId: `stripe_${event.id}`,
});
break;
}
}
(b) Marketing email clicks — the server resolves the user before they hit your site
A customer clicks a link in your re-engagement email. The link goes through your redirect endpoint (/r?u=<signedUserId>&c=<campaign>), which logs the click, attributes the campaign, and 302s them to the destination. By the time the browser SDK boots on the landing page, the click is already in the past — and the user might not even be logged in yet. Fire email_link_clicked from the redirect handler, with developerUserId resolved from the signed query param. See the full pattern in Marketing email click attribution below.
(c) Cron jobs & scheduled work — nothing client-side ever runs
A nightly Cloud Function checks for subscriptions renewing in 24h and fires subscription_renewal_processed for each one. No browser, no client SDK, no session — the event is purely server-driven. Same shape applies to: trial-expiry warnings, weekly summary emails sent, dunning state transitions, daily report generation. If it's cron.yaml or a Cloud Scheduler trigger, the event has to come from the server.
(d) Server-only state changes — admin overrides, ticket resolutions, programmatic grants
Your support team grants a free month from the admin console; an ops engineer resolves a support ticket; a workflow auto-tags a churn-risk account. The customer never sees the action — it happens entirely in your internal tools. Fire admin_grant_applied / support_ticket_resolved / churn_tag_applied from the server, and the customer timeline gets the full picture.
(e) SSR'd pages — the SDK hasn't booted yet
A Next.js App Router page renders server-side at /dashboard with the user's plan, recent activity, and personalised pricing. The render itself is a meaningful customer-journey event ("the dashboard was actually delivered, not just requested") — but the client SDK won't page.viewed until hydration finishes, which may be hundreds of ms later and won't fire at all on bot-skipped renders. Fire dashboard_rendered from the server component, identifying with the authenticated user. The browser SDK still fires its own page.viewed on hydration; the server event is the canonical one for "we definitely served this page."
If the event would land in a "we don't actually know if the action happened" column when only the client fires it — the action is authoritative from the server's perspective — fire it from the server. If both client and server can see the action (e.g. a button click that calls an API), fire from one side only, never both. See anti-pattern (a) below.
The Node SDK path
For Node 18+ services — Express, Fastify, Next.js API routes, Cloud Functions, Lambda, Cloud Run, plain TS scripts — the default is @cross-deck/node. The SDK encodes every operational concern that a server-side telemetry pipeline needs to handle, and gets the defaults right for serverless deployment.
npm install @cross-deck/node
Initialise once per process (not per request — see Cron & scheduled work for why) and call track() wherever you'd otherwise log:
import { CrossdeckServer } from "@cross-deck/node";
const crossdeck = new CrossdeckServer({
secretKey: process.env.CROSSDECK_SECRET_KEY!, // cd_sk_test_… or cd_sk_live_…
});
export async function handlePurchase(req, res) {
// ... charge logic ...
crossdeck.track({
name: "purchase_completed",
developerUserId: req.user.id,
properties: { amount: 1900, currency: "usd" },
});
res.status(200).json({ ok: true });
}
That single track() call — synchronous, fire-and-forget — gives you:
- Durable in-memory queue. Events are buffered locally and flushed in batches of up to 20 (configurable via
eventFlushBatchSize) every 1.5s of idle time (eventFlushIntervalMs), whichever fires first. A burst of 100track()calls produces ~5 HTTP requests, not 100. - Per-batch
Idempotency-Keyheader. Every flush generates a fresh batch ID; the same ID is reused on retry so the server dedupes if the first attempt actually succeeded but the response got lost. - Exponential-backoff retry with full jitter. Failed flushes retry on the next interval (with the same batch and same key, so server-side dedup still applies). Honours
Retry-Afterfrom the server when present. - Flush-on-exit.
process.on('beforeExit')+SIGTERM+SIGINThandlers drain the queue, bounded byflushOnExitTimeoutMs(default 2s). Critical for Cloud Functions, Lambdas, and short-lived CLI processes. - Runtime enrichment.
serviceName,serviceVersion,appVersion, and process metadata attach to every event automatically. On Cloud Functions / Lambda the SDK readsK_SERVICE/AWS_LAMBDA_FUNCTION_NAMEwith no config. - Boot heartbeat. The first instantiation phones home in the background, so the dashboard's "Verify install" surface flips LIVE without you calling
await crossdeck.heartbeat()manually. Fire-and-forget; never blocks.
For the full set of init options, return shapes, and method signatures, see the Node SDK reference. The rest of this doc covers patterns built on top of these primitives.
The raw HTTP path
Sometimes you can't add a dependency. Legacy PHP shared host, a Go binary you don't own the deploy of, a Ruby app on a shared host without npm, a Bash cron job. Crossdeck's ingest endpoint is plain HTTP + JSON — call it from anything that speaks TLS.
Endpoint shape
POST https://api.cross-deck.com/v1/events
Authorization: Bearer cd_sk_live_xxxxxxxxxxxx
Content-Type: application/json
Idempotency-Key: batch_a1b2c3d4e5f6 # strongly recommended
Crossdeck-Sdk-Version: [email protected] # identifies your caller in our logs
{
"events": [
{
"eventId": "evt_local_abc123",
"name": "purchase_completed",
"timestamp": 1717891200000,
"properties": { "amount": 1900, "currency": "usd" },
"developerUserId": "user_847"
}
]
}
Required per-event fields: name (non-empty), and exactly one of developerUserId, anonymousId, or crossdeckCustomerId (see Identity linking). Strongly recommended: a stable eventId per event for server-side dedup, and timestamp (ms epoch) for the actual moment the event occurred — defaults to the server's receipt time otherwise.
curl example
curl -X POST https://api.cross-deck.com/v1/events \
-H "Authorization: Bearer $CROSSDECK_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: batch-$(uuidgen)" \
-H "Crossdeck-Sdk-Version: [email protected]" \
-d '{
"events": [
{
"eventId": "evt_'$(uuidgen)'",
"name": "support_ticket_resolved",
"timestamp": '$(date +%s000)',
"developerUserId": "user_847",
"properties": { "ticketId": "T-12345", "resolutionMinutes": 42 }
}
]
}'
Wire limits
| Limit | Value | Behaviour on breach |
|---|---|---|
| Events per batch | 1–100 | 400 invalid_param_value with the first failing index |
Per-event properties size (JSON-serialised) | 8 KB | 400 invalid_param_value on the offending event |
| Request body size | 1 MB | 413 from the platform; chunk into smaller batches |
developerUserId length | 1–256 chars | 400 invalid_param_value |
| Per-project rate limit | Token-bucket, default ~1000 events/sec sustained with burst | 429 with Retry-After |
Responses
Success is 202 Accepted with { "object": "list", "received": <n>, "env": "production" | "sandbox" }. The 202 means the batch was queued for asynchronous indexing — identity resolution and rollup happens in a background worker, so events appear in the dashboard within a few seconds of ingest. Common failure modes:
401 authentication_error— secret key missing, malformed, or revoked.400 invalid_param_value— batch shape rejected. The response body identifies the bad event index and field.403 permission_errorwith codeorigin_not_allowed— you sent a publishable key from a server context with noOriginmatch. Use a secret key. See Authentication.429 rate_limit_error— your project exceeded its bucket. HonourRetry-After.5xx— backend transient. Retry with the sameIdempotency-Key.
The Node SDK does both for free. On raw HTTP you're responsible for: (1) generating a stable Idempotency-Key per batch and re-using it on retry, (2) backing off on 429/5xx with jitter, (3) batching small writes together so you don't pummel the endpoint with one-event batches, and (4) draining anything in-flight before your process exits. If your stack could use the Node SDK, the operational cost difference is significant — use it.
Authentication — secret vs publishable
Crossdeck has two API key types and they're not interchangeable for server-side ingest.
| Key type | Prefix | Where it goes | Can ingest events with arbitrary identity? |
|---|---|---|---|
| Publishable | cd_pub_test_ / cd_pub_live_ | Browser bundles, mobile apps, anywhere the bundle ships to a user device | No — limited to the device's own anonymousId; origin-allowlisted on web keys |
| Secret | cd_sk_test_ / cd_sk_live_ | Server processes only — Node, Cloud Functions, Lambdas, cron jobs, webhook receivers | Yes — can fire events with any developerUserId, any anonymousId, or any crossdeckCustomerId |
Server-side ingest must use a secret key. The reason is structural, not just policy:
- A publishable key authenticates a device, not a developer. It's safe to put in a browser bundle because it can't impersonate other users — the wire is origin-locked, and an attacker stealing the key only impersonates themselves.
- A secret key authenticates a developer. It can fire events on behalf of any user in your project, because that's what server-side ingest needs — your webhook receiver knows the customer's
userIdfrom Stripe metadata, and it needs to firepurchase_completedon that user's timeline. - If you put a publishable key on your server, Crossdeck enforces the same origin allowlist a browser would — and your server doesn't send an
Originheader, so the request403s withorigin_not_allowed. This is the failure mode you'll hit if you accidentally re-use the browser key on the backend. - Secret keys are stored hashed at rest in Crossdeck — we never see the plaintext after creation. Treat them like Stripe restricted keys: rotate via the dashboard, never check them into git, source them from your secret manager.
// Wrong — publishable key on the server. Will 403 on every request.
const crossdeck = new CrossdeckServer({
secretKey: "cd_pub_live_xxxxxxxxxxxx", // constructor throws — must start with cd_sk_
});
// Right — secret key from your secret manager.
const crossdeck = new CrossdeckServer({
secretKey: process.env.CROSSDECK_SECRET_KEY!,
});
The Node SDK validates the key prefix at construction time — passing a cd_pub_* key throws CrossdeckError({ code: "invalid_secret_key" }) at new CrossdeckServer({...}), so misconfiguration surfaces at boot instead of at first event. The same key prefix determines environment: cd_sk_test_* writes to your sandbox project, cd_sk_live_* writes to production. There's no environment option on the server SDK — the key is the source of truth.
Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault, GitHub Actions encrypted secrets — anywhere that isn't a .env committed to git. Crossdeck's dashboard lists every secret key by its prefix + last-rotated date so you can audit usage; rotating is two clicks and a redeploy.
Identity linking — keeping the timeline continuous
Every event in Crossdeck attributes to exactly one customer (crossdeckCustomerId, abbreviated cdcust_*) — the canonical identity behind which all rails, events, errors, and entitlements coalesce. The only question that matters for server-side ingest is: which cdcust does this event belong to?
The Crossdeck backend resolves this from one of three hints you can attach to each event:
| Field | Set by | When to use |
|---|---|---|
developerUserId | Your auth system | You've already identified the user — JWT, session, signed query param, Stripe metadata |
anonymousId | The web SDK (auto-minted on first boot, format anon_*) | The same visitor's browser fired identify() earlier — you want to maintain their timeline before login |
crossdeckCustomerId | Returned by Crossdeck after identify() / webhook reconciliation | You stored the cdcust on your user record and want to skip the resolution step |
Server-side, you'll hit one of three modes depending on what the server already knows about the customer.
Mode A — known userId: the server resolved the user already
Your auth middleware extracted the user from a JWT / session cookie. You have their stable application user ID. Fire the event with developerUserId:
crossdeck.track({
name: "checkout_started",
developerUserId: req.user.id, // e.g. "user_847"
properties: { cartTotal: 4200 },
});
Crossdeck attributes to that customer immediately. If the userId is brand new (no prior alias), this is the first identification — Crossdeck mints a fresh cdcust. If a previous client-side identify(userId, anonymousId) already aliased this user, the event lands on the existing cdcust and merges with their browser timeline.
Mode B — known anonymousId from the client
The user isn't logged in yet (no userId), but the browser SDK minted an anonymousId on first page load. You can forward it to the server via cookie or header, then re-use it for server-side events that the same visitor caused:
// Client-side: the SDK exposes it via getState()
const anonId = Crossdeck.getState().anonymousId;
// → "anon_mp10knb…"
// Forward to server (e.g. as a cookie set on every page response,
// or in a fetch header for an XHR request):
fetch("/api/abandon-cart", {
method: "POST",
headers: { "X-Anon-Id": anonId },
});
// Server-side handler:
const anonId = req.headers["x-anon-id"];
crossdeck.track({
name: "server_abandon_detected",
anonymousId: anonId,
properties: { cartId: req.body.cartId },
});
The event lands on the same cdcust the browser's events are landing on — the timeline stays unbroken. Critical for funnels that span "anonymous visitor browses → server detects abandon → user signs up later," because Crossdeck back-attributes everything once the userId arrives.
Mode C — both: the server fires identify() and pins the alias
The strongest pattern. When the server learns the user's userId (e.g. they completed signup, the email-click resolved their identity), fire identify() first with both the userId and the anonymousId the browser established earlier. Crossdeck aliases the two — all past anonymous events get back-attributed to the new cdcust:
await crossdeck.identify(
"user_847", // developer userId — the one from your auth system
"anon_mp10knb…", // the anonymousId the browser minted earlier
{ email: "[email protected]" },
);
// All subsequent events on EITHER hint land on the same cdcust.
crossdeck.track({
name: "signup_completed",
developerUserId: "user_847",
});
Unlike track(), identify() is a direct HTTP call (it's a transactional aliasing operation, not telemetry — see Identify users for the full model). It returns once the alias is durable. The browser SDK's next call automatically picks up the new cdcust via the entitlement cache populate-and-subscribe pattern.
The wrong way — events without identity
Firing track() from the server with no developerUserId / anonymousId / crossdeckCustomerId hint is the most common server-side mistake. The events don't fail: they land in your project's events stream, they bump dashboard totals, they're queryable in raw SQL. But they never attribute to a customer — they're orphan events. From the customer-360 view, the user simply didn't do that action.
The Node SDK protects against this for events triggered from a captureError() path (which has no per-request context) by auto-filling with a processAnonymousId minted per-process. But for explicit track() calls, the responsibility is yours — always attach one of the three hints. The raw HTTP endpoint rejects events with zero identity hints at validation (400 invalid_param_value with code missing_identity_hint).
The endpoint validates that you supply exactly one identity field per event. Supplying multiple isn't dangerous — the backend would resolve them deterministically — but a single hint per event keeps your code readable when you read events back six months later in a support investigation. If you need to alias two identifiers, that's identify(userId, anonymousId)'s job, not track()'s.
Idempotency
Server-side telemetry has more replay opportunities than client-side. Webhooks redeliver. Lambda handlers retry. Queue workers re-process messages after a crash. Without idempotency, every retry doubles a metric. Crossdeck's ingest endpoint dedupes on two layers:
- Per-batch
Idempotency-Keyheader. The HTTP layer's standard idempotency contract — same key + same body within ~24h returns the original response without re-applying writes. Used by the SDK's batched flush path; same key gets re-sent on retry. - Per-event
eventId. The endpoint deduplicates events on the tuple(projectId, eventId)via Firestore'screate-onlywrites — even if you split the same event across two different batches, only one row lands. Recommended for every server-side event so retries are safe even when the batch grouping changes.
Node SDK — automatic
The SDK generates an Idempotency-Key per batch (format batch_*) and reuses it across retries of the same batch. Every event also gets an auto-generated eventId (format evt_*) before enqueue. You can override per-event when you need replay safety to span the source event ID:
crossdeck.track({
name: "purchase_completed",
developerUserId: pi.metadata.userId,
// Use Stripe's event.id as the upstream replay key.
// If Stripe redelivers this webhook hours later, we re-fire with the
// same eventId; Crossdeck's per-event dedup absorbs the duplicate.
eventId: `stripe_${event.id}`,
properties: { amount: pi.amount, currency: pi.currency },
});
For the ingest() method (sync bulk-import variant — no queue, no batching), the SDK auto-generates a batch Idempotency-Key unless you override it via options.idempotencyKey. This is the right hook for bulk-replay-this-batch-of-events flows: pin the key to something stable across re-runs.
Raw HTTP — manual
Generate a UUID per logical batch, send it in the Idempotency-Key header, and reuse the same key on every retry of that batch. Generate an eventId per event and reuse it on retries too. The combination ensures replays at either the batch or the event level are absorbed:
// Pseudocode for a Go webhook receiver retrying on transient 5xx.
batchKey := uuid.New().String()
for attempt := 1; attempt <= 5; attempt++ {
resp := postEvents(events, batchKey)
if resp.StatusCode < 500 { break }
time.Sleep(jitteredBackoff(attempt))
}
The pattern that matters: derive your idempotency key from the upstream event ID when you have one (Stripe event.id, SQS message ID, Kafka offset). That way the replay safety extends all the way up to the source — you can replay an entire day of Stripe webhooks through your receiver and Crossdeck dedupes every single one.
Webhook receiver pattern
Webhook receivers are the canonical server-side telemetry path. Stripe / Apple / Google Play fire a signed webhook → your endpoint verifies the signature → you switch on the event type → you fire one or more Crossdeck events → you return 2xx.
Full Express pattern (Stripe)
import express from "express";
import Stripe from "stripe";
import { CrossdeckServer } from "@cross-deck/node";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const crossdeck = new CrossdeckServer({
secretKey: process.env.CROSSDECK_SECRET_KEY!,
});
const app = express();
// Stripe sends raw bytes — mount express.raw for THIS route specifically.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return res.status(400).send(`Webhook signature failed: ${err}`);
}
switch (event.type) {
case "payment_intent.succeeded": {
const pi = event.data.object as Stripe.PaymentIntent;
crossdeck.track({
name: "purchase_completed",
developerUserId: pi.metadata.userId,
eventId: `stripe_${event.id}`,
properties: {
amount: pi.amount,
currency: pi.currency,
stripePaymentIntentId: pi.id,
},
});
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
crossdeck.track({
name: "subscription_cancelled",
developerUserId: sub.metadata.userId,
eventId: `stripe_${event.id}`,
properties: { stripeSubscriptionId: sub.id },
});
break;
}
}
// Flush before returning — important on serverless because the
// process may be frozen the moment we 200. On a long-running
// Express server you could rely on the batched queue + interval flush.
await crossdeck.flush();
res.status(200).send();
},
);
What's load-bearing in this code
- Raw body parsing on the webhook route. Stripe's signature is computed over the raw bytes — if Express's
json()middleware has already reparsed the body, the HMAC won't match. Same rule applies to Crossdeck's own webhook signature verification — useexpress.raw()on signed-webhook routes. - Verify before tracking. Never call
crossdeck.track()from an unverified webhook handler — an attacker who hits your endpoint without a valid signature could otherwise inject events with arbitraryuserId. Verify first; track on the verified path only. eventIdpinned to the upstream event ID. Stripe redelivers on slow 2xx and on any 5xx response. The sameevent.idacross redeliveries means Crossdeck dedupes — yourpurchase_completedcount is replay-safe at the source.await crossdeck.flush()before 200. Serverless platforms freeze the process the instant the handler returns. Without an explicit flush, your durable queue may have events buffered that never make it out. On long-running servers (Express on a VM, Fargate task) the interval-driven flush is enough — but adding the explicitflush()in the webhook path costs nothing and removes a category of bug.- Don't fire-and-forget on serverless. Returning 200 before the SDK has flushed loses events on cold paths. The trade-off is ~50-200ms of added latency on the webhook 200 response — well within Stripe's 30s ack budget, and the events actually land.
It's tempting to fan out one Stripe webhook to four Crossdeck events ("payment_initiated," "payment_pending," "payment_succeeded," "payment_completed"). Resist. A Stripe payment_intent.succeeded is one state change; emit one event. The dashboard's funnel UI joins events into journeys later — having one canonical event per transition makes the funnels readable.
Server-side identify
The Node SDK exposes identify(userId, anonymousId, traits) for server-driven aliasing. Same conceptual purpose as the web SDK's Crossdeck.identify(userId), but with explicit control over the anonymousId being aliased — because on the server you have to provide it; nothing's auto-stored.
await crossdeck.identify(
"user_847", // stable application user ID
"anon_mp10knb…", // the anonymousId from the browser SDK
{
email: "[email protected]",
traits: {
plan: "free",
signupSource: "email",
},
},
);
When server-side identify is the right call
- Email-click attribution — the click hits your server before the browser SDK boots. The server-side
identify()+track("email_link_clicked")combo lands both the alias and the event before the user even sees the destination page. - OAuth callback / SSO completion — the auth provider redirects the user back to
/auth/callback; the server resolves the user;identify()immediately so the post-redirect render lands events on the rightcdcust. - Server-side signup — a backend endpoint creates the user record. Fire
identify()there rather than waiting for the client to call it after redirect. - Account merging — your application merges two accounts; use
aliasIdentity()on the server to link them in Crossdeck too.
If the only identification path is "the user signs in on the browser, then we know who they are" — you don't need server-side identify() at all. The browser SDK handles that case end-to-end. Server-side identify is for the cases where the server learns about the user before the browser has a chance to.
Unlike client-side identify(), server-side identify() returns a Promise<AliasResult> — it's a direct HTTP call, not queued. The AliasResult tells you the resolved crossdeckCustomerId, which identifiers got linked, and whether a background merge is pending (when the same user surfaces under multiple rails before reconciliation). Store the crossdeckCustomerId on your user record if you want to skip the resolution step on subsequent events — use crossdeckCustomerId as the identity hint instead of developerUserId.
Marketing email click attribution — the full pattern
The classic example of "the server needs to fire events before the client has a chance to." A re-engagement email goes out from your ESP; the user clicks a link 36 hours later from their inbox; the link goes through your redirect handler; you need to attribute the click to a campaign and identify the user — before their browser even hits the destination page.
Email link shape
https://app.example.com/r?u=eyJ1IjoidXNlcl84NDcifQ.signature&c=may15-re-engage&dest=/dashboard
Three params: u (signed user ID — use a JWT or HMAC over { userId, exp }), c (campaign slug), dest (the path to redirect to). Sign the u param so users can't tamper with someone else's userId just by editing the URL.
Redirect handler
app.get("/r", async (req, res) => {
const { u, c, dest } = req.query;
let userId: string | null = null;
try {
const payload = jwt.verify(u as string, process.env.EMAIL_LINK_SECRET!);
userId = (payload as { u: string }).u;
} catch {
// Bad / expired signature — log it but still redirect, so user isn't blocked.
logger.warn("email link signature invalid");
}
if (userId) {
// Identify FIRST so the click event lands on the right cdcust.
// We don't have an anonymousId yet — the user isn't on our site —
// but identifying with userId alone is fine; the next browser-side
// identify() call from the destination page will alias them naturally.
await crossdeck.identify(userId, mintEphemeralAnonId(), {
// You may not have an email here without a DB lookup — skip if so.
});
crossdeck.track({
name: "email_link_clicked",
developerUserId: userId,
eventId: `emailclick_${u}`, // dedupe on the signed token — same click = same event
properties: {
campaign: c as string,
destination: dest as string,
userAgent: req.headers["user-agent"],
},
});
// Critical: flush before the 302 — the platform may freeze us.
await crossdeck.flush();
}
res.redirect(302, (dest as string) ?? "/");
});
By the time the user's browser hits /dashboard and the web SDK boots, the email_link_clicked event is already on the customer's timeline. The browser's subsequent page.viewed + identify() calls land on the same cdcust. Funnels that ask "of users who clicked the may15-re-engage email, how many viewed pricing within 24h" become trivially queryable.
If your u param is just a plain user ID without a signature, anyone can craft a URL that fires email_link_clicked on someone else's timeline. Use JWT or HMAC; verify before identifying. The signed-token approach also lets you dedupe with eventId: \`emailclick_${u}\` — the same email click can't fire twice even if the user opens the link twice from different devices.
Cron & scheduled work
Cron jobs are the second-most-common server-side telemetry source after webhook receivers. The patterns differ slightly depending on the runtime.
Cloud Functions (v2)
import { onSchedule } from "firebase-functions/v2/scheduler";
import { CrossdeckServer } from "@cross-deck/node";
// Initialise at MODULE LOAD, not inside the handler — so the boot
// heartbeat doesn't fire on every invocation and the entitlement cache
// is reused across warm invocations.
const crossdeck = new CrossdeckServer({
secretKey: process.env.CROSSDECK_SECRET_KEY!,
serviceName: "renewal-cron",
});
export const processRenewals = onSchedule("every 1 hours", async () => {
const dueRenewals = await findRenewalsDueWithinNextHour();
for (const r of dueRenewals) {
crossdeck.track({
name: "subscription_renewal_processed",
developerUserId: r.userId,
eventId: `renewal_${r.subscriptionId}_${r.cycleId}`,
properties: {
subscriptionId: r.subscriptionId,
amount: r.amount,
currency: r.currency,
},
});
}
// Drain before return. The Cloud Function platform freezes the
// container the moment this async function resolves. flush-on-exit
// handlers can be preempted by the platform's SIGKILL — explicit
// flush is the safety net.
await crossdeck.flush();
});
AWS Lambda
import { CrossdeckServer } from "@cross-deck/node";
// Module scope = init once per container, reused across warm invocations.
const crossdeck = new CrossdeckServer({
secretKey: process.env.CROSSDECK_SECRET_KEY!,
serviceName: process.env.AWS_LAMBDA_FUNCTION_NAME, // auto-detected too
});
export const handler = async (event) => {
for (const record of event.Records) {
crossdeck.track({
name: "queue_message_processed",
developerUserId: extractUserId(record),
eventId: record.messageId, // SQS message IDs are unique per delivery
properties: { queue: event.queueArn, body: record.body },
});
}
await crossdeck.flush();
return { statusCode: 200 };
};
Why module-scope init matters
Cold-start cost is real on serverless. new CrossdeckServer(...) runs synchronous setup (validating the key prefix, building the runtime info object, installing exit handlers) plus an async background boot heartbeat. Doing this once per container — at module load — amortises the cost across every warm invocation. Doing it per-invocation in the handler body adds ~5-15ms to every cold path and makes the boot heartbeat fire dozens of times per minute on hot platforms.
The container-local entitlement cache (60s TTL by default) is also reset on every new instance — so module-scope init also means hot invocations skip the entitlement-fetch round-trip when the cache is warm.
Flush-on-exit
The single biggest source of "missing events" on serverless: the function returns, the platform freezes/kills the container, and the events that were sitting in the in-memory queue at that moment evaporate. The Node SDK ships protection against this by default.
What the SDK does automatically
At construction time (unless flushOnExit: false), the SDK installs three handlers:
process.on("beforeExit")— Node's normal event-loop-exhausted exit. Fires when there's nothing left to do; the SDK gets one chance to flush before the runtime exits.process.on("SIGTERM")— the platform's "please shut down" signal. Cloud Run, ECS, Kubernetes, and bare metal orchestration use this for graceful shutdown.process.on("SIGINT")— Ctrl+C in dev. So your local test runs don't lose events when you cancel them.
Each handler awaits eventQueue.flush() with a bounded timeout (flushOnExitTimeoutMs, default 2000ms). Two seconds is enough to flush a handful of events over a healthy network without blocking shutdown past the platform's own SIGKILL grace period (typically 5–10 seconds after SIGTERM).
When the automatic handlers aren't enough
Some serverless platforms don't fire beforeExit reliably. Cloud Functions can freeze the process the instant the handler's returned promise resolves; AWS Lambda can hibernate the container without a clean shutdown signal at all. The defensive pattern is to await crossdeck.flush() explicitly at the end of every handler:
export const handler = async (event) => {
crossdeck.track({ name: "work_started" , developerUserId: event.userId });
await doWork(event);
crossdeck.track({ name: "work_finished", developerUserId: event.userId });
await crossdeck.flush(); // drain before return
return { ok: true };
};
flush() is idempotent and a no-op when the queue is empty, so calling it at every exit point costs nothing on warm-path invocations where the interval-driven flush already drained the queue.
If flush() hangs
Almost always a network issue — your Lambda VPC has no NAT/egress to api.cross-deck.com, or a captive portal in a dev environment is intercepting the request. The SDK's per-request timeout (timeoutMs, default 15s) caps it eventually, but in serverless that's longer than your handler should run. Set a shorter timeoutMs for cron/Lambda workloads (5s is reasonable) so a hung network surfaces quickly instead of consuming your whole runtime budget.
Batching & backpressure
The Node SDK batches events server-side before sending. Defaults are tuned for steady-state workloads — bursty loads might want tuning.
| Option | Default | What it controls |
|---|---|---|
eventFlushBatchSize | 20 | Max events buffered before forced flush. Parity with web SDK. |
eventFlushIntervalMs | 1500 | Idle ms after last track() before flushing. Combines with batch size — whichever fires first. |
flushOnExit | true | Install beforeExit/SIGTERM/SIGINT drain handlers. |
flushOnExitTimeoutMs | 2000 | Max time the exit handlers block shutdown waiting for the queue. |
timeoutMs | 15_000 | Per-request abort timeout. Drop to 5s for serverless cold paths. |
Server-side wire limits
The ingest endpoint enforces hard limits per request, regardless of SDK config:
- 1–100 events per batch. The SDK won't put more than 20 per batch by default; if you raise
eventFlushBatchSizebeyond 100 the server-side validator rejects the batch. - 8 KB per event's
properties(JSON-serialised). Larger payloads reject at validation withproperties_too_large. - ~1 MB total request body (Cloud Functions default — Crossdeck doesn't override).
- Per-project rate limit via token-bucket — defaults are generous (~1000 events/sec sustained with burst), but you'll see
429withRetry-Afterif you exceed it. Raise the limit by opening a dashboard support ticket.
Backpressure events
The SDK emits two operational events you can subscribe to for monitoring:
crossdeck.on("queue.flush_succeeded", ({ batchSize, durationMs }) => {
metrics.histogram("crossdeck.flush.batch_size", batchSize);
metrics.histogram("crossdeck.flush.latency_ms", durationMs);
});
crossdeck.on("queue.flush_failed", ({ error, attempt, nextRetryMs }) => {
logger.warn("crossdeck flush failed", { error, attempt, nextRetryMs });
});
Useful for catching upstream degradation (your project's rate limit got tightened, the backend is having a bad day, your secret key was rotated and the new env hasn't deployed yet) before it shows up as missing events in dashboards.
Anti-patterns
(a) Firing the same event from both client AND server
"Just to be safe, we fire purchase_completed from the client AND from the Stripe webhook." Now you have two events per purchase. Funnels double-count. Revenue rolls up wrong. The "right" side to fire from is whichever has the most authoritative signal — for a payment, that's the webhook. Pick one and only one. If you need a client-side "user clicked Pay" event, name it differently (checkout_submitted) so it doesn't collide.
(b) Using a publishable key on the server
Crossdeck enforces origin allowlisting on publishable keys. Your server doesn't send Origin, so the request 403s with origin_not_allowed. The Node SDK actually throws at construction time when you pass a cd_pub_* key — but if you're calling raw HTTP, you'll see the failure as a 403 with no events landing.
(c) Calling identify() with email instead of stable userId
Emails change. People update their address, merge accounts, switch to a corporate domain. Your userId (or its equivalent — Firebase Auth's uid, Stripe's customer.id, your internal user-table PK) is stable for the lifetime of the user. Use the stable ID for identify()'s first argument; attach email as a trait, not as the identity.
// Wrong — email-as-userId. Now their analytics break when they change emails.
await crossdeck.identify(user.email, anonId);
// Right — stable userId; email as a trait.
await crossdeck.identify(user.id, anonId, { email: user.email });
(d) Skipping idempotency on webhook replays
Stripe redelivers webhooks on any non-2xx response (and on slow 2xx). If your handler doesn't set eventId or Idempotency-Key derived from the upstream event ID, every redelivery double-counts. Use eventId: \`stripe_${event.id}\` as the standard pattern — replay-safe at the source.
(e) Fire-and-forget without await or flush() on serverless
crossdeck.track() is sync void — it returns the instant the event is enqueued, not when it lands. On long-running Express servers that's fine: the queue drains on the interval timer. On Lambda / Cloud Functions, the platform can freeze your process the moment your handler resolves — and the queue evaporates. Always await crossdeck.flush() before returning from a serverless handler.
Troubleshooting
Events sent, nothing appearing in the dashboard
Three common causes:
- Wrong environment. Your secret key is
cd_sk_test_*but you're looking at the production dashboard, or vice versa. Check the key prefix and the dashboard env toggle. - Wrong project. Your secret key belongs to a different project than the one you're viewing. The dashboard's "Verify install" panel surfaces the key fingerprint Crossdeck saw — check it against the prefix in your env vars.
- Auth rejected silently. If you're using raw HTTP and not checking the response, a 401 will look identical to success from your code's perspective. Add error handling, or use the Node SDK which throws.
Events appearing but not attributing to a customer
The event has no identity hint, or the hint references an identifier Crossdeck doesn't know yet. Check that every event includes exactly one of developerUserId / anonymousId / crossdeckCustomerId. If you're firing from a webhook receiver, make sure your userId is actually stored in Stripe's metadata when you create payment intents — many bugs trace back to "we forgot to attach metadata at PI creation, and now the webhook has no way to identify the user."
Identity linking not working — client + server events on different customers
The most common cause: the anonymousId the server attaches doesn't match the one the browser stored. Confirm with Crossdeck.getState().anonymousId in the browser console; compare to what your server actually sees in the forwarded cookie/header. The web SDK's anonymousId format is anon_* — if your server is sending something else (a UUID, a session ID), the alias never matches.
Second common cause: userId format mismatch. If your client sends identify("847") and your server sends identify("user_847"), those are two different identities in Crossdeck. Normalise on one shape end-to-end.
Hitting rate limits (429 rate_limit_error)
Default project quota is generous but finite (~1000 events/sec sustained, with burst capacity). Hitting it usually means a backfill or replay job is flooding the endpoint. Two paths:
- Slow down. Honour
Retry-Afterin the 429 response — the Node SDK does this automatically. - Raise the limit. Open a support ticket from the dashboard; tell us the project ID and the workload shape. We adjust the bucket parameters from a Firestore doc — no deploy needed.
flush() hangs on serverless exit
Almost always a network egress issue. Your Lambda is in a VPC with no NAT gateway, or your Cloud Function has a firewall rule blocking outbound HTTPS. The SDK's per-request timeout caps it eventually (15s default), but that's longer than most serverless handler budgets. Set timeoutMs: 5000 when constructing the SDK on serverless so a hung network surfaces fast, and check your VPC config.
"cd_pub_ key is not a valid secret key"
Constructor-time error from the Node SDK when the supplied key doesn't start with cd_sk_. You've likely re-used your browser publishable key in a server env. Generate a secret key from the dashboard's API keys page, store it in your secret manager, and redeploy.
Related
- Node SDK reference — every init option, method signature, return shape, and error code for
@cross-deck/node. - Identify users — the full identity model: anonymous IDs, developer IDs, customer IDs, aliasing, the merge worker.
- Web SDK reference — the browser-side counterpart. Same identity model, complementary primitives.
- API keys & authentication — publishable vs secret, rotation, origin allowlists.
- Webhook verification (Node SDK § Webhooks) — Crossdeck-issued webhooks have signed bodies;
verifyWebhookSignature()is the safe-by-default helper.
Last updated May 15, 2026. Code-truth references: sdks/node/src/crossdeck-server.ts, sdks/node/src/http.ts, sdks/node/src/event-queue.ts, backend/src/api/v1-events.ts, backend/src/api/v1-auth.ts.