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.

Nothing is sent anywhere. A publishable key is saved in this browser. A secret key is kept only until you reload.

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. Every contact belongs to one brand: a brand API key sets it, and an organization API key must send brandId
eventyesYour event name. Names that start with $ are dropped without an error
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.

effectiveAt and a join's historyStartsAt can predate event retention: they are business membership history. FounderHQ stores the declaration's event at receipt time and preserves the original effective date on the membership. Future effective dates are subject to clock clamping. Historical event imports still follow the organization's retention limit.

accountContextToken({ account, userId, idempotencyKey, ttlSeconds }) returns short-lived authority for one account. Put it in the checkout metadata as fhq_account_context, or let a mobile app pass it back with a purchase. ttlSeconds runs from 60 to 86400.

const { contextToken } = await events.accountContextToken({
  account: "workspace_123",
  userId: "user_42",
  idempotencyKey: "checkout_8f3a",
});
// Add { fhq_account_context: contextToken } to the checkout metadata.

Declare the account with upsertAccount and the payer with accountMembership (or add them by hand in FounderHQ). You can request the token right after those calls: FounderHQ issues it at once, even while the declarations are still being processed. The token does not prove that the account existed when it was issued. The payment checks it: the account must be confirmed and userId must be a member of it at the time of the payment. FounderHQ waits up to 15 minutes for both. After that, the payment uses the payer's memberships, as without a token. The call fails only for a retired account key or a deleted account; create the checkout without the token then.

The payment also checks that the checkout visitor is the payer. Put the browser SDK's checkoutMetadata() in the same metadata, and call identify with the same userId in the browser before checkout. If the browser never identifies the payer, each payment with a token waits the full 15 minutes and then uses the payer's memberships.

When Revenue owner is Organization, this token is the reliable way to give a payment to an account. Without it, FounderHQ uses the payer's memberships: a payer in exactly one account pays for that account, and a payer in several accounts, or in none, pays as a contact. A subscription keeps the owner of its first payment on every renewal. See Which account gets a payment.

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, from half a second up to 30 seconds, on network trouble and on every refusal except 400, 401, and 403. Those three mean the request itself is wrong, so they 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, and none of its events are stored. The SDK retries it on its own schedule. 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