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-nodeThe 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 },
});| Field | Required | What it is |
|---|---|---|
contact | yes | externalId, email, or phone. At least one. Also takes brandId and timezone |
event | yes | Your event name. Names that start with $ are refused |
properties | no | Your own values |
account | no | An account key, or an object with key, contextToken, and spanId |
sessionId | no | The visit this event belongs to. See Join backend events to a visit |
timestamp | no | A Date or an ISO string. Defaults to now |
idempotencyKey | no | Your 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.
| Field | Required | What it is |
|---|---|---|
idempotencyKey | yes | Your delivery key. Retries must reuse it |
transactionId | yes | The payment processor's stable transaction ID |
amountMinor | yes | Integer minor units. Not always cents |
currency | yes | Three letters, such as USD |
transactionRefType | no | What transactionId points at, such as payment_intent or invoice_payment |
kind | no | payment, refund, dispute_lost, or credit_note |
occurredAt | no | A Date or an ISO string |
taxMinor, feeMinor | no | Tax and processor fee, in minor units |
settlementAmountMinor, settlementCurrency, fxRate | no | What you were actually paid, when it differs |
checkoutVisitorId | no | The browser's anonymous ID, so the payment keeps its attribution |
providerCustomerId, providerSubscriptionId | no | The processor's customer and subscription IDs |
customerEmail | no | The payer's email |
originalTransactionId, originalRefType | no | The payment a refund or dispute points back to |
attributionWindowDays | no | 30, 60, 90, or 180 |
historicalImport | no | Marks a backfill |
metadata | no | Your own values |
subscription | no | status, 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.
| Option | Type | Default | What it does |
|---|---|---|---|
host | string | https://i.getfounderhq.com | Where events are sent |
flushAt | number | 20 | Sends a batch once this many events are queued. Held between 1 and 100 |
flushIntervalMs | number | 5000 | Sends a batch on this timer. 0 turns the timer off |
maxQueueSize | number | 10000 | Largest queue held in memory. The oldest events are dropped past it, and reported to onError |
maxRetries | number | 3 | Retries per batch |
onError | (error, events) => void | none | Called with the events that were dropped or rejected |
The flush timer never keeps your process alive on its own.
Methods
| Method | Returns | What 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.