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