# Node (https://www.getfounderhq.com/docs/analytics/sdks/node)

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

<Callout title="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](/analytics/getting-started).
</Callout>

## Install

```npm
npm install @founderhq/events-node
```

The SDK needs Node 18 or later.

## Initialize

```ts
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

```ts
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](/analytics/recipes/session-stitching) |
| `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:

```ts
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:

```ts
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

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

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

```ts
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.

```ts
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](/analytics/revenue/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](/analytics/revenue/mobile-purchase-claims).

## Checkout metadata

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

```ts
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](/analytics/revenue/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](/analytics/protocol-reference/envelope#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](/analytics/protocol-reference) for the wire
format and the event shape the server accepts.
