Getting Started
Server-side tracking
Track from your backend with flowgrid-sdk/server — a Node/edge-safe client scoped to identity, feature usage, ecommerce & revenue and API / server error tracking. Same wire contract as the browser SDK, so server and browser events resolve to one identity in your dashboard.
Overview
Some of the most valuable events never happen in the browser: the OAuth callback that actually creates the account, the API route a feature runs through, the webhook that fails at 3am. FlowGridServer covers exactly that surface:
- Identity — identify users, update traits, alias, reset on logout, and the auth-level events (signup / login / active-user) fired from server auth flows.
- Feature usage —
feature_usageevents from API routes, background jobs and webhooks. - Ecommerce & revenue — purchases, refunds, order lifecycle and subscription MRR events, fired from payment webhooks where the money is actually confirmed.
- Errors — server exceptions as
error_events with route / method / status context.
Runs anywhere fetch runs
No window, no document, no storage, no Node built-ins — only global fetch. Works in Node ≥ 18, Next.js API routes and middleware, Vercel serverless & edge, Cloudflare Workers, Bun and Deno.
Init — once per process
Initialise in a dedicated lib file and import the instance everywhere server-side.init() is idempotent — calling it twice returns the same instance.
1import { FlowGridServer } from "flowgrid-sdk/server";
2
3export const fg = FlowGridServer.init({
4 webId: process.env.NEXT_PUBLIC_FLOWGRID_WEB_ID!, // same site id as the browser SDK
5 apiKey: process.env.FLOWGRID_API_KEY!, // PRIVATE key — server env only
6 // endpoint: "https://core.flow-grid.xyz", // default
7 // environment: "production", // defaults to NODE_ENV
8});Key hygiene
The server client requires your private API key. Keep it in a server-only env var (no NEXT_PUBLIC_ prefix) — never ship it to the browser.
Identity & auth events
trackAuth() is the one-call path for server auth flows — it identifies the user (writing the identity store, so they appear as a Known visitor), fires the signup/login event, and marks them active for DAU/WAU/MAU:
1import { fg } from "@/lib/flowgrid.server";
2
3// In your OAuth callback / session endpoint, once the user is known:
4await fg.trackAuth({
5 event: "login", // or "signup"
6 userId: user.id,
7 email: user.email, // email + name make the identity resolvable
8 name: user.name,
9 method: "google",
10});The individual operations are all available too — same semantics as the browser SDK:
1await fg.identifyUser(user.id, { email: user.email, plan: "pro" });
2await fg.updateTraits(user.id, { plan: "enterprise" }); // after an upgrade
3await fg.aliasUser("old_id", user.id); // ID migrations
4await fg.pingActiveUser(user.id, { email: user.email }); // DAU/WAU/MAU signal
5await fg.signup(user.id, "google", "pricing"); // funnel "Signed up" stage
6await fg.login(user.id, "google"); // returning-user signal
7await fg.logout(user.id); // reset identity on sign-outLink the web session
Server events need a visitor_id. When you have the request, pass the visitor's real browser id so the server event links to their web session — visitorIdFromCookies() reads it from the request cookies:
1import { cookies } from "next/headers";
2import { visitorIdFromCookies } from "flowgrid-sdk/server";
3import { fg } from "@/lib/flowgrid.server";
4
5const visitorId = visitorIdFromCookies(await cookies());
6
7await fg.trackAuth(
8 { event: "signup", userId: user.id, email: user.email, method: "google" },
9 { visitorId }, // ← anonymous browsing session → known user, linked
10);No cookie available?
In jobs and webhooks there's no request cookie. Omit visitorId and the client derives a stable synthetic visitor from the userId (fg_srv_<hash>), so a user's server events always attribute to one visitor. Fully anonymous events share a per-process visitor.
Feature usage
Features that run through an API — exports, imports, integrations, AI actions — are tracked where they actually execute. Events land in the same Feature Usage dashboards as browser events (see the Feature Usage guide).
1import { fg } from "@/lib/flowgrid.server";
2
3// Direct:
4await fg.trackFeature({
5 featureId: "csv_export",
6 featureName: "CSV Export",
7 userId: user.id,
8 properties: { rows: 12_000 },
9});
10
11// Or bind once and use semantic verbs (server twin of fg.defineFeature):
12const csvExport = fg.defineFeature("csv_export", "CSV Export", { category: "reports" });
13await csvExport.used({ userId: user.id, rows: 12_000 });
14await csvExport.completed({ userId: user.id });
15await csvExport.abandoned({ userId: user.id, reason: "timeout" });Ecommerce & revenue
Browser purchase events are structurally lossy — ad blockers, tabs closed on the checkout redirect, interrupted payments. Your payment webhookis where money is actually confirmed, so it's the authoritative place to record revenue. Server events use the same ecommercecontract as the browser SDK and land in the same revenue & monetization dashboards.
1import { fg } from "@/lib/flowgrid.server";
2
3// checkout.session.completed — the purchase, recorded where it's confirmed
4await fg.trackPurchase({
5 order: {
6 orderId: session.id,
7 items, // CartItem[] — same shape as the browser SDK
8 subtotal, discountTotal: 0, shippingTotal: 0, taxTotal,
9 total: session.amount_total / 100,
10 currency: "USD",
11 payment: { method: "credit_card", provider: "stripe" },
12 },
13 userId: session.client_reference_id,
14});
15
16// charge.refunded — keeps net revenue accurate
17await fg.trackRefund({
18 orderId,
19 refundId: refund.id,
20 amount: refund.amount / 100,
21 currency: "USD",
22 reason: "customer_request",
23 isFullRefund: true,
24 userId,
25});Order lifecycle events from fulfilment systems:
1await fg.trackOrderStatus(orderId, "processing", "pending", { userId });
2await fg.trackOrderShipped(orderId, { method: "express", cost: 5, trackingNumber });
3await fg.trackOrderDelivered(orderId);Subscription / MRR lifecycle — one method, discriminated on event. These power the MRR and churn dashboards, and billing state lives on your server, so emit them from your billing webhooks:
1// customer.subscription.created
2await fg.trackSubscription({
3 event: "start", // "change" | "cancel" | "renew" | "trial_converted" | "trial_expired"
4 subscriptionId: sub.id,
5 userId,
6 plan: "professional",
7 mrr: { amount: 9900, currency: "USD" }, // smallest currency unit (cents)
8 billingCycle: "monthly",
9 trialDays: 14,
10});
11
12// customer.subscription.deleted
13await fg.trackSubscription({
14 event: "cancel",
15 subscriptionId: sub.id,
16 userId,
17 reason: sub.cancellation_details?.reason ?? "unknown",
18 lostMrr: { amount: 9900, currency: "USD" },
19});Revenue attribution — Stripe & Lemon Squeezy
A payment webhook arrives with no cookies — on its own, FlowGrid can only attach a purchase to a synthetic server visitor. To attribute revenue to the real visitor (their session, landing page, UTM campaign), the visitor and session ids must round-trip through the payment provider: embed them in the checkout's metadata when you create it, read them back in the webhook. Both FlowGrid trackers mirror the ids to cookies (visitor_id / session_id, npm SDK: fg_session_id) precisely so your checkout route can read them.
Leg 1 — embed the ids at checkout creation:
1import { cookies } from "next/headers";
2import { checkoutAttributionFromCookies } from "flowgrid-sdk/server";
3
4// Stripe — goes into the checkout session's metadata
5const session = await stripe.checkout.sessions.create({
6 line_items, mode: "payment", success_url, cancel_url,
7 metadata: { ...checkoutAttributionFromCookies(await cookies()) },
8 // adds: flowgrid_visitor_id + flowgrid_session_id
9});
10
11// Lemon Squeezy — goes into checkoutData.custom
12const checkout = await createCheckout(storeId, variantId, {
13 checkoutData: {
14 custom: { ...checkoutAttributionFromCookies(await cookies()) },
15 },
16});Creating the checkout from the browser instead? The browser SDK exposes the same keys: fg.checkoutAttribution() — POST them to your checkout endpoint alongside the price id.
Leg 2 — read them back in the webhook and attach them to the revenue event. attributionFromMetadata() unwraps Stripe objects (.metadata) and Lemon Squeezy payloads (.meta.custom_data) automatically:
1import { attributionFromMetadata } from "flowgrid-sdk/server";
2import { fg } from "@/lib/flowgrid.server";
3
4// checkout.session.completed
5const { visitorId, sessionId } = attributionFromMetadata(event.data.object);
6
7await fg.trackPurchase(
8 { order, userId: session.client_reference_id },
9 { visitorId, sessionId }, // ← revenue joins the real visitor's journey
10);
11
12// Works for subscriptions too:
13await fg.trackSubscription(
14 { event: "start", subscriptionId, userId, plan, mrr },
15 { visitorId, sessionId },
16);What this buys you
With the real visitorId + sessionId on the purchase, revenue ties back to the exact session that produced it — the campaign, referrer and landing page — instead of appearing as unattributed server traffic. You can additionally pass attribution: { source, medium, campaign } to trackPurchase if you store campaign data yourself.
API & server error tracking
Thrown values of any shape become error_events. Pass route / method / statusCode so the dashboard can break failures down per endpoint:
1import { fg } from "@/lib/flowgrid.server";
2
3try {
4 // …
5} catch (err) {
6 await fg.trackError(err, {
7 route: "/api/teams/edit/[teamId]",
8 method: "PATCH",
9 statusCode: 500,
10 userId: user.id,
11 });
12 throw err;
13}Or wrap the whole handler — the error is tracked and rethrown unchanged, so your framework's error handling behaves exactly as before:
1export const PATCH = fg.withErrorTracking(
2 async (req: NextRequest) => { /* … */ },
3 { route: "/api/teams/edit/[teamId]", method: "PATCH" },
4);
5
6// Opt-in process-level capture (Node only) — reports uncaughtException /
7// unhandledRejection as fatal, observes only, never swallows:
8const detach = fg.captureUncaught();FlowGrid never reports itself
Internal notes (messages starting with [FlowGrid]) are filtered out before sending, so FlowGrid's own delivery hiccups never pollute your error analytics.
Delivery guarantees
- Never throws. Every method resolves
{ ok, status?, reason? }— a tracking failure can never break your API route. - Retries. Network failures and 5xx responses retry with jittered exponential backoff (2 retries, 10s timeout by default — tunable in init).
- Server context. Every event is stamped with
_server: true, the runtime, and_environment(fromNODE_ENV), so you can segment server vs. browser traffic. - Fire-and-forget friendly. Don't want to await?
void fg.trackFeature(…)is safe — failures are debug-noted, never raised.
Consent & compliance
The browser consent banner gates browsertracking. Server events sit outside those gates by design: they are your backend's own records — auth events, API feature usage, server errors — collected under your contractual / legitimate-interest basis with your signed-in users, not cookie consent.
If your policy extends cookie consent to server-side analytics, check your stored consent state before calling the server client. See the Cookie consent guide for exactly what each consent category covers.