Documentation
SDKs

Node

Use the FounderHQ Node SDK.

Send the events only your backend knows about: signups, plan changes, payments, and account membership.

Before you start

You need a secret key (fhq_sk_...). Keep it on the server, never in a browser or an app. See Getting started.

Install

npm install @founderhq/events-node

The SDK needs Node 18 or later.

Initialize

import { FounderHqNode } from "@founderhq/events-node";

export const events = new FounderHqNode("fhq_sk_XXXX");

createFounderHqNodeClient(apiKey, options) builds the same client. The constructor throws when you pass a publishable key.

This SDK works differently from the client SDKs. It keeps no device state. There are no sessions, no automatic capture, and no consent switches. You name the contact on every call, and you name the account on every call that needs one.

Capture events

events.capture({
  contact: { externalId: "user_42", email: "jane@acme.com" },
  event: "subscription.upgraded",
  properties: { plan: "growth", mrr: 99 },
});
FieldRequiredWhat it is
contactyesexternalId, email, or phone. At least one. Also takes brandId and timezone
eventyesYour event name. Names that start with $ are refused
propertiesnoYour own values
accountnoAn account key, or an object with key, contextToken, and spanId
sessionIdnoThe visit this event belongs to. See Join backend events to a visit
timestampnoA Date or an ISO string. Defaults to now
idempotencyKeynoYour dedupe key. Defaults to a generated UUID

capture returns at once. The event goes out with the next batch.

Pass sessionId when a visit caused the event, so it reads as part of that visit rather than as loose activity:

events.capture({
  contact: { externalId: "user_42" },
  event: "subscription.upgraded",
  sessionId: request.headers.get("x-founderhq-session-id"),
});

Leave it out for work no visit caused, such as a nightly job or a webhook from a payment provider. Those events are meant to stand on their own.

Add $display_name to properties when the event name is not what you want people to read:

events.capture({
  contact: { externalId: "user_42" },
  event: "subscription.upgraded",
  properties: { $display_name: "Subscription upgraded", plan: "growth" },
});

Reuse one spanId across the calls that belong to the same piece of account activity. Node keeps no ambient account state, so leaving account out is how you clear it.

Accounts

events.upsertAccount({
  key: "workspace_123",
  properties: { plan: "growth", seats: 12 },
});

upsertAccount updates account properties. It never says that a contact belongs to the account.

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",
});

accountMembership records a join or a leave your backend knows about. A leave keeps later activity out of that account. Only the Node SDK can do this. Use state: "retracted" when the membership was wrong from the start, not when somebody left.

accountContextToken({ account, userId, idempotencyKey, ttlSeconds }) returns short-lived authority for one account, so a mobile app can pass it back with a purchase. ttlSeconds runs from 60 to 86400.

Revenue

Revenue goes to the ledger first, and FounderHQ derives the analytics event after it reconciles.

const ack = await events.captureRevenue({
  idempotencyKey: "webhook_evt_3StableDeliveryId",
  transactionId: "pi_3StableProcessorId",
  transactionRefType: "payment_intent",
  amountMinor: 1299,
  currency: "USD",
  checkoutVisitorId: anonymousId,
});

captureRevenue resolves once the delivery is accepted. The answer carries deliveryId, status, and created.

FieldRequiredWhat it is
idempotencyKeyyesYour delivery key. Retries must reuse it
transactionIdyesThe payment processor's stable transaction ID
amountMinoryesInteger minor units. Not always cents
currencyyesThree letters, such as USD
transactionRefTypenoWhat transactionId points at, such as payment_intent or invoice_payment
kindnopayment, refund, dispute_lost, or credit_note
occurredAtnoA Date or an ISO string
taxMinor, feeMinornoTax and processor fee, in minor units
settlementAmountMinor, settlementCurrency, fxRatenoWhat you were actually paid, when it differs
checkoutVisitorIdnoThe browser's anonymous ID, so the payment keeps its attribution
providerCustomerId, providerSubscriptionIdnoThe processor's customer and subscription IDs
customerEmailnoThe payer's email
originalTransactionId, originalRefTypenoThe payment a refund or dispute points back to
attributionWindowDaysno30, 60, 90, or 180
historicalImportnoMarks a backfill
metadatanoYour own values
subscriptionnostatus, priceMinor, interval, currentPeriodEnd, quantity, and plan. Needs providerSubscriptionId

Ordinary capture refuses the $revenue event. Revenue only enters through this command. See Generic revenue API.

claimMobileSubscription({ account, userId, purchase, confirmation, idempotencyKey }) moves the future revenue of a store subscription to an account. It takes confirmation: "move_future_revenue", because it changes who earns the revenue from now on. See Mobile purchase claims.

Checkout metadata

When your server creates the checkout, attach the IDs the browser gave you.

import { checkoutMetadata } from "@founderhq/events-node";

const metadata = checkoutMetadata({ anonymousId, sessionId });
// { fhq_anonymous_id: "...", fhq_session_id: "..." }

This helper is pure. It sends nothing, and it throws when either ID is empty. See Checkout metadata and payment links.

Batching and delivery

Events queue in memory and go out in order as one batch of up to 100 events. A batch retries with growing delays on network trouble and 5xx answers. Rejections from auth or validation are not retried. You see them through onError.

Every event carries an idempotency key, so a retried batch never double-counts.

Ingest budgets are counted per organization, not per key or brand. A batch over the budget comes back as 429 with a Retry-After header, and none of its events are stored. See Size ceilings and rate limits.

OptionTypeDefaultWhat it does
hoststringhttps://i.getfounderhq.comWhere events are sent
flushAtnumber20Sends a batch once this many events are queued. Held between 1 and 100
flushIntervalMsnumber5000Sends a batch on this timer. 0 turns the timer off
maxQueueSizenumber10000Largest queue held in memory. The oldest events are dropped past it, and reported to onError
maxRetriesnumber3Retries per batch
onError(error, events) => voidnoneCalled with the events that were dropped or rejected

The flush timer never keeps your process alive on its own.

Methods

MethodReturnsWhat it does
capture(event)Queues one server event
upsertAccount(account)Updates account properties
accountMembership(membership)Records a join, leave, or retraction
accountContextToken(input)Promise<{ key, contextToken, expiresAt }>Issues short-lived account authority
claimMobileSubscription(input)Moves future store-subscription revenue to an account
captureRevenue(command)Promise<ack>Sends one revenue command
flush()Promise<void>Sends every queued event, in order
shutdown()Promise<void>Flushes, stops the timer, and closes the client

Call await events.shutdown() when your process exits, so nothing is left in the queue.

Next

Read the protocol reference for the wire format and the event shape the server accepts.

AI agent or LLM? Read this page as markdown

On this page