@founderhq/events-node
v1.1.1
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. $platform is added
to properties; SDK name and version travel only in the
FounderHq-Sdk-Info header, 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);Tell the tracker who serves your site, so it reads the visitor's real address. Without it every crawler reads as unverified, because FounderHQ has no address to check against the crawler's published list.
| Your site is served by | Setting |
| --- | --- |
| Vercel | proxy: "vercel" |
| Cloudflare | proxy: "cloudflare" |
| Amazon CloudFront | proxy: "cloudfront" — add CloudFront-Viewer-Address to the distribution's origin request policy; CloudFront does not send it otherwise |
| Anything else | proxy: { ipHeader: "…" } naming a header your proxy overwrites on every request, or resolveIp |
Never name a header a visitor can send themselves: a forged crawler address would then verify. Forwarding headers are never trusted as a group.
The recorded URL uses the request's Host header. X-Forwarded-Host is read
only when a proxy is declared and Host is just an address (127.0.0.1:3000),
because many proxies pass a client's own X-Forwarded-Host through untouched.
Use resolveUrl if your proxy rewrites Host to an internal name.
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://i.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", "cloudfront", 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.
Release notes
1.1.1
- Fixes the install from npm: the package now names
@founderhq/events-coreby version. No change in behavior.
1.1.0
proxy: "cloudfront"readsCloudFront-Viewer-Address, port removed. Add the header to your distribution's origin request policy.- Behind a declared proxy the recorded URL uses the public
Hostrather than the address the app binds to, which read0.0.0.0in Next.js. - The crawler catalogue is rebuilt from vendor documentation, and every entry names its
source. Added: Mistral, Amazon's search and user fetchers, Meta's web indexer, DuckDuckGo, Baidu, Yandex, xAI, Kimi, Qwen, DeepSeek, Zhipu, Cohere, Allen AI, ByteDance's TikTokSpider. - Removed
Google-ExtendedandApplebot-Extended: both vendors say these arerobots.txtwords only and never send requests under them. A request that names either is no longer counted as a crawler. - Removed
BingPreview; the rebuilt catalogue lists only the crawlers Bing documents (bingbot,msnbot). OAI-SearchBotandClaude-SearchBotare now indexing; their vendors describe them as building a search index.YouBotis now indexing too, under the vendoryourather thanother, andGoogleOtheris now training.- A crawler's name inside a documentation link in the user agent no longer counts as that crawler.
0.8.0
- The default host is now
https://i.getfounderhq.com.app.getfounderhq.comno longer serves SDK ingest.
