@founderhq/events-node
v0.6.0
Published
FounderHQ server-side event ingest SDK for Node.js
Readme
@founderhq/events-node
Server-side FounderHQ Events capture for Node.js (18+). Use it for server-truth growth events — signups, activations, subscription and payment changes — that browsers cannot be trusted to report.
import { FounderHqNode } from "@founderhq/events-node";
const events = new FounderHqNode("fhq_sk_server_key");
events.capture({
contact: { externalId: "user_42", email: "[email protected]" },
event: "subscription.upgraded",
properties: { plan: "growth", mrr: 99 },
});
await events.shutdown(); // flush on process exitWhen server code creates a Stripe/Dodo checkout from browser-provided IDs, use the pure metadata helper:
import { checkoutMetadata } from "@founderhq/events-node";
const metadata = checkoutMetadata({ anonymousId, sessionId });
// { fhq_anonymous_id: "...", fhq_session_id: "..." }Events are queued in memory and delivered in order as
POST /api/events { batch: [...] } with the secret key in
Authorization: Bearer. Batches retry with exponential backoff on network or
5xx failures; auth and validation rejections are surfaced through onError
and never retried. Each event carries an idempotency key (auto-generated
UUID, or pass idempotencyKey) so retried batches never double-count.
The Node SDK uses Events v2's single property namespace: $lib,
$lib_version, and $platform are added to properties, and no context
object is sent. Node deliberately has no autocapture or client sessions.
Account context is stateless and belongs on each event. Reusing a caller-owned
spanId groups related account activity without creating ambient SDK state:
events.capture({
contact: { externalId: "user_42" },
account: { key: "workspace_123", spanId: request.accountSpanId },
event: "report.created",
});
events.upsertAccount({
key: "workspace_123",
properties: { plan: "growth" },
});
events.accountMembership({
account: "workspace_123",
userId: "user_42",
state: "left",
effectiveAt: new Date("2026-08-17T10:30:00Z"),
idempotencyKey: "membership_user_42_left_2026_08_17",
});upsertAccount sends a contact-free $groupidentify, so it updates account
properties but never implies membership. Omitting account from a later
capture is the stateless equivalent of clearing context.
Use accountMembership when your backend knows a user joined or left an
account. It keeps later activity from being attributed to an account the user
has left. effectiveAt defaults to now, and a stable idempotencyKey makes
webhook retries safe.
Use state: "retracted" when the membership was wrong from the start, not when
someone departed.
Revenue is sent through the ledger before an analytics event is derived:
await events.captureRevenue({
idempotencyKey: "webhook_evt_3StableDeliveryId",
transactionId: "pi_3StableProcessorId",
transactionRefType: "payment_intent",
amountMinor: 1299,
currency: "USD",
checkoutVisitorId: anonymousId,
});Use the payment rail's stable transaction ID so retries and cross-adapter
copies reconcile to one movement. Amounts are integer minor units (not always
cents: currencies can have zero or three minor digits). Ordinary $revenue
capture remains blocked; the worker emits it only after ledger reconciliation.
Contacts resolve by externalId, email, or phone — at least one is
required. brandId scopes the contact when the key is org-wide. Timestamps
default to now and accept Date or ISO strings. The legacy context input is
deprecated and promoted into the single property namespace for compatibility.
The package executes every applicable Node fixture from
events-core/fixtures/conformance-v2.json exactly with injected clock, UUID,
and transport providers, comparing complete requests and normalized outcomes.
Secret keys only: the constructor throws on a publishable (fhq_pk_) key.
Use @founderhq/events in browsers instead.
Crawler tracking
AI answer engines and search crawlers do not run JavaScript, so browser
analytics never sees them. createCrawlerTracker records those server-side
fetches and reports them per brand as AI visibility in FounderHQ. Use the
same secret key. Docs: https://www.getfounderhq.com/docs/analytics/ai-visibility
import { createCrawlerTracker } from "@founderhq/events-node/crawlers";
const crawlerTracker = createCrawlerTracker({
secretKey: process.env.FOUNDERHQ_SECRET_KEY!,
});The /crawlers subpath is safe to import in Edge middleware: it does not load
the Node-only event client or node:crypto.
Next.js middleware (proxy.ts in Next.js 16) must pass NextFetchEvent so
delivery stays alive with waitUntil() after middleware returns:
import type { NextFetchEvent, NextRequest } from "next/server";
import { NextResponse } from "next/server";
const crawlerTracker = createCrawlerTracker({
secretKey: process.env.FOUNDERHQ_SECRET_KEY!,
proxy: "vercel",
deliveryMode: "request-scoped",
});
function middleware(_request: NextRequest, _event: NextFetchEvent) {
return NextResponse.next();
}
export default crawlerTracker.withNextCrawlerTracking(middleware);Use proxy: "cloudflare" when Cloudflare is the last trusted proxy. For a
custom stack, pass one authoritative { ipHeader } policy or a resolveIp
callback. Forwarding headers are never trusted as an undifferentiated group.
Express:
// Configure Express `trust proxy` for your actual proxy topology first.
// The tracker uses Express's resolved req.ip.
app.use(crawlerTracker.expressHandler());Plain Node:
createServer((request, response) => {
response.end("ok");
crawlerTracker.trackRequest(request, { statusCode: response.statusCode });
});trackRequest classifies the user agent in memory, queues the record, and
returns. It never throws and never awaits the network. Long-lived Node servers
batch with a timer. Request-scoped Next middleware flushes through
waitUntil(). Each request body stays below 60 KiB as well as the 50-record
cap; a failed batch is dropped, never retried. Static assets, /_next/*,
/api/*, /i/*, and non-GET requests are skipped. URLs and user agents are
bounded before entering the byte-bounded queue.
| Option | Default | What it does |
| --- | --- | --- |
| secretKey | required | fhq_sk_ key; the constructor throws on a missing or publishable key |
| endpoint | https://app.getfounderhq.com/i/v2/crawlers | Where batches are sent |
| enabledCategories | AI_ANSWERS, INDEXING, TRAINING, OTHER | Categories recorded on your server |
| proxy | — | One authoritative policy: "vercel", "cloudflare", or { ipHeader, take? } |
| resolveIp / resolveUrl | — | Caller-owned resolution for custom proxy or framework behavior |
| deliveryMode | long-lived | Use request-scoped with the Next adapter; it disables batching timers |
| flushAt | 50 | Queue size that triggers a send (1–50) |
| flushIntervalMs | 5000 | Longest wait before a send; 0 disables the timer |
| maxQueueSize | 500 | Records kept in memory; oldest are dropped first |
| maxQueueBytes | 262144 | Serialized bytes kept in memory; oldest are dropped first |
| fetch | global fetch | Custom fetch implementation |
| onDrop | — | Called asynchronously with a count when records are dropped |
flush(), shutdown(), and getStats() (queued, dropped, delivered)
are available on every tracker. classifyCrawlerUserAgent and
CRAWLER_CATALOG are exported for your own checks.
For Next pass-through and rewrite responses, status is recorded as unknown:
middleware cannot observe the final page's 404 or 500. Redirect statuses
and Express's final response status are recorded when known.
