# FounderHQ Documentation (https://www.getfounderhq.com/docs)
FounderHQ tells you which channel brought a customer and which one got
paid. These docs show you how to install it, how to read it, and how to
build on it.
## Pick your product
Capture events on web, mobile, and server. See which channel and
content paid you.
Build onboarding flows and quizzes once. Render them in your app on
every platform.
Connect your email, SMS, WhatsApp, and push providers for
cross-channel sequences.
## Going somewhere specific?
Install an SDK and see your first event. About 5 minutes.
Connect Stripe, Dodo, RevenueCat, Apple, or Google Play.
Every endpoint, generated from the source schemas.
MCP server, Markdown routes, and llms.txt for your agent.
## Reading these docs with an agent
Every page is available as Markdown: add `.md` to any URL, or request
`Accept: text/markdown`. The whole site is in
[`/docs/llms.txt`](/llms.txt) and [`/docs/llms-full.txt`](/llms-full.txt).
To let a coding agent search these docs live, connect the MCP server:
```bash
claude mcp add --transport http founderhq-docs https://www.getfounderhq.com/docs/mcp
```
See [AI resources](/analytics/ai-resources) for the details and for other
clients.
# AI visibility (https://www.getfounderhq.com/docs/analytics/ai-visibility)
AI answer engines and search services do not run JavaScript, so browser
analytics never sees them. AI visibility records these server-side page
fetches for each brand. It stays separate from visitors, sessions, contacts,
and revenue, and it never changes how your site responds.
Each fetch lands in one of four categories:
| Category | SDK value | What it means |
| --- | --- | --- |
| AI answer engines | `AI_ANSWERS` | A service fetched the page to answer a question or research a topic (for example ChatGPT, Claude, Perplexity). |
| Search indexing | `INDEXING` | A search service discovered or revisited the page (for example Googlebot, Bingbot). |
| Model training | `TRAINING` | A provider collected public content for model development (for example GPTBot, ClaudeBot, CCBot). |
| Other automated fetches | `OTHER` | A user agent that looks automated but is not in the maintained catalog yet. |
Create a secret key in FounderHQ: **Settings → API Keys → Server events
ingest**. It starts with `fhq_sk_` and must stay on your server. A publishable
key (`fhq_pk_`) is refused with `403`.
## Install
```npm
npm install @founderhq/events-node
```
The package needs Node 18 or later. Crawler tracking ships in `0.3.0` and
later.
## Next.js middleware
Create the tracker once, then wrap your existing middleware. In Next.js 16 the
file is `proxy.ts` and the export is named `proxy`; the wrapper is the same.
```ts title="middleware.ts"
import { createCrawlerTracker } from "@founderhq/events-node/crawlers";
import {
NextResponse,
type NextFetchEvent,
type NextRequest,
} from "next/server";
const crawlerTracker = createCrawlerTracker({
secretKey: process.env.FOUNDERHQ_SECRET_KEY!,
proxy: "vercel",
deliveryMode: "request-scoped",
});
function middleware(_request: NextRequest, _event: NextFetchEvent) {
return NextResponse.next();
}
export default crawlerTracker.withNextCrawlerTracking(middleware);
```
If your middleware already redirects, rewrites, or checks authentication, keep
that function unchanged, accept its `NextFetchEvent`, and wrap it in the final
export. The adapter uses `event.waitUntil()` so a low-volume batch is delivered
after middleware returns. Pass-through and rewrite status is stored as unknown
because middleware cannot observe the final page response. Redirect and thrown
error statuses are recorded. The adapter never changes the response.
The `/crawlers` subpath is safe in Edge middleware and does not import
`node:crypto`. Use `proxy: "cloudflare"` when Cloudflare is the final trusted
proxy instead of Vercel.
## Express
Add the handler before your routes. It records the final response status when
the response finishes.
```ts
import express from "express";
import { createCrawlerTracker } from "@founderhq/events-node/crawlers";
const app = express();
const crawlerTracker = createCrawlerTracker({
secretKey: process.env.FOUNDERHQ_SECRET_KEY!,
});
// Configure Express `trust proxy` for your real proxy topology first.
// The tracker reads Express's resolved req.ip.
app.use(crawlerTracker.expressHandler());
```
## Plain Node
Call `trackRequest` yourself from any server. It accepts the Node
`IncomingMessage`, a Fetch `Request`, or any object with `method`, `url`, and
`headers`.
```ts
import { createServer } from "node:http";
import { createCrawlerTracker } from "@founderhq/events-node/crawlers";
const crawlerTracker = createCrawlerTracker({
secretKey: process.env.FOUNDERHQ_SECRET_KEY!,
});
createServer((request, response) => {
response.end("ok");
crawlerTracker.trackRequest(request, { statusCode: response.statusCode });
}).listen(3000);
process.on("SIGTERM", () => {
void crawlerTracker.shutdown();
});
```
`trackRequest` returns at once. It never throws and never waits on the
network.
## Options
| Option | Default | What it does |
| --- | --- | --- |
| `secretKey` | required | Your `fhq_sk_` key. The constructor throws on a missing or publishable key so a misconfiguration is caught at startup. |
| `endpoint` | `https://i.getfounderhq.com/i/v2/crawlers` | Where batches are sent. Change it only for a self-hosted or regional FounderHQ. |
| `enabledCategories` | all four | Categories recorded on your server. Fetches outside the list are never queued or sent. |
| `proxy` | none | Trust exactly one platform policy: `"vercel"`, `"cloudflare"`, or your own `{ ipHeader, take? }`. |
| `resolveIp` / `resolveUrl` | none | Resolve the client IP or canonical public URL yourself for a custom proxy chain. |
| `deliveryMode` | `long-lived` | Use `request-scoped` with `withNextCrawlerTracking`; batching timers are reserved for long-lived Node servers. |
| `flushAt` | `50` | Queue size that triggers a send (1–50). |
| `flushIntervalMs` | `5000` | Longest time a record waits before a send. `0` sends only when `flushAt` is reached or on `flush()`. |
| `maxQueueSize` | `500` | Records kept in memory. When the queue is full, the oldest records are dropped. |
| `maxQueueBytes` | `262144` | Serialized bytes kept in memory. The oldest records are dropped when either queue bound is reached. |
| `fetch` | global `fetch` | Custom fetch implementation. |
| `onDrop` | — | Called asynchronously with a count when records are dropped or a batch fails. |
Every tracker also exposes `flush()`, `shutdown()`, and `getStats()`
(`queued`, `dropped`, `delivered`).
### Delivery guarantees
- Classification runs in memory from the user agent only.
- Batches stay below both 50 records and 60 KiB, then send with `keepalive` and
a 2 second timeout.
- URLs and user agents are bounded before they enter the byte-bounded queue.
- A failed batch is dropped, never retried, and never blocks a request.
- Static assets, `/_next/*`, `/api/*`, `/i/*`, and non-GET requests are
skipped before they reach the queue.
## What is sent and what is stored
| Sent by the SDK | Stored by FounderHQ |
| --- | --- |
| Request URL | Host and path (path trimmed to 2,048 characters) |
| User agent | User agent trimmed to 512 characters, plus vendor, bot name, and category |
| Visitor IP (when available) | A salted hash scoped to your brand when FounderHQ's dedicated hash salt is configured; otherwise no hash. The raw IP is used once to verify the provider, then discarded. |
| Response status, when the framework can observe it | Response status or unknown |
| Time of the fetch | Time of the fetch |
No cookies, headers, bodies, or visitor identity are sent. Individual records
are kept for 90 days. Daily summaries power the reports.
## Verification
FounderHQ checks each crawler identity against its own published IP feed and
refreshes every feed independently every six hours. A failed source does not
discard successful updates from the same provider.
| State | Meaning |
| --- | --- |
| Verified | The IP is inside the provider's published ranges. |
| Likely impersonation | A recent successful feed for that exact crawler excludes the IP. |
| Unverified | No usable IP arrived, the crawler's feed is missing, or its latest snapshot/refresh is stale or failed. |
| Not verifiable | The provider publishes no ranges. |
Providers with published ranges today: OpenAI, Perplexity, Google, and
Microsoft (Bing). Anthropic, Apple, ByteDance, Meta, Amazon, and Common Crawl
show as **not verifiable**.
## Choose what appears
Each brand has four switches in **Settings → AI Visibility**: AI answer
engines, Search indexing, Model training, and Other automated fetches. A
switch that is off drops that category at ingest. Existing history stays
available. The same page shows the install snippet with the brand's current
categories and a button that opens the **Server events ingest** key preset.
The SDK can also limit categories at install time:
```ts
createCrawlerTracker({
secretKey: process.env.FOUNDERHQ_SECRET_KEY!,
enabledCategories: ["AI_ANSWERS", "INDEXING"],
});
```
## What the reports show
The **AI visibility** card appears in Events and Publish Overview. Choose 7,
28, or 90 days to see hits by category, provider, top fetched page, and
discovery file such as `llms.txt`, `robots.txt`, Markdown exports, and your
sitemap. The source filter applies to every number and page in the card.
A published article matches its published host and path. Verified activity is
labelled **Verified OpenAI fetches**; unverified or unverifiable activity uses
qualified wording such as **Reported as Anthropic**.
## Troubleshooting
**No hits show up.** Confirm the key starts with `fhq_sk_` and belongs to the
brand you are viewing. Check that the wrapped middleware or handler runs for
your public pages (Next.js `matcher` rules can exclude them). Call
`getStats()` after a known fetch; `dropped` above zero means delivery failed,
`queued` means the batch has not flushed yet. Remember that the category
switches in Settings also drop fetches.
**The SDK throws at startup.** `createCrawlerTracker` throws when
`secretKey` is empty or starts with `fhq_pk_`. Load the secret from the server
environment.
**`403` from the endpoint.** The key is publishable, lacks the server events
permission, or is disabled. Mint a new key from the **Server events ingest**
preset.
**Everything shows as unverified.** Choose the single platform policy that
owns the authoritative client-IP header (`proxy: "vercel"` or
`proxy: "cloudflare"`), configure Express `trust proxy`, or pass `resolveIp`.
Do not trust a collection of forwarding headers: a client-reachable origin can
otherwise accept a forged crawler address.
**Self-hosted behind a proxy.** Make sure `x-forwarded-host` and
`x-forwarded-proto` reach your app so recorded URLs carry your public host,
and allow outbound HTTPS to `i.getfounderhq.com`.
# Analytics (https://www.getfounderhq.com/docs/analytics)
Analytics is the core of FounderHQ: capture events on any platform, tie
them to contacts, and see which channel and content paid you.
Pick your platform and see your first event. About 5 minutes.
Contacts, sessions, attribution, consent, and bot traffic.
Web, React Native, iOS, Android, and Node references.
Connect a payment provider and attribute the money.
See when AI answer engines and search services fetch your pages.
Every public endpoint, with a live playground.
The wire protocol, for building your own client.
Lovable, Replit, v0, Bolt, Ghost, Next.js, and end-to-end payments.
Connect your coding agent to these docs.
# Embed on the web (https://www.getfounderhq.com/docs/journeys/embed-on-the-web)
Render a published Journey inside your React app. About 5 minutes.
No code? Use the [public link, the embed, or the phone preview](/journeys/share)
from the **Share** button in the editor instead.
You need a published Journey in FounderHQ, its Journey ID, and a
publishable key (`fhq_pk_XXXX`) from the **Journey embed** preset. Open
Journeys in FounderHQ to publish one and to copy its ID. See
[Journeys](/journeys) for how keys and allowed origins work.
## Install the package
```npm
npm install @founderhq/journeys
```
## Render the Journey
Import the component and the stylesheet. The stylesheet also loads the
bundled Inter and Lora fonts, so the Journey looks the same in every
browser without a font request to a third party.
```tsx
import { Journey } from "@founderhq/journeys";
import "@founderhq/journeys/styles.css";
export function Onboarding() {
return (
);
}
```
`apiKey` and `journeyId` are always required. With nothing else passed, the
SDK fetches the published config from FounderHQ and renders it.
`storageKey` is where the SDK keeps answers and the current screen in the
browser. Give each Journey its own key so a person who reloads the page
lands back where they left off.
Next.js App Router? Add `"use client"` at the top of the file. The Journey
is an interactive client component.
## What the SDK does on render
Everything goes to `https://app.getfounderhq.com`. You do not configure the
host.
1. **Check access.** `POST /api/v1/journeys/{journeyId}/validate` with the
key in an `Authorization: Bearer` header. FounderHQ checks the key, the
request origin, the brand, the Journeys entitlement, and that the
Journey is published with an active version. A failure renders the error
state and nothing else is sent.
2. **Fetch the config.** `GET /api/v1/journeys/{journeyId}` returns the
published config, its `revisionId`, its `version`, and the Journey's
contact-linking mode. Pass a `config` prop to skip this call — step 1
still runs, so a local config never bypasses access control.
3. **Render and capture.** Step events queue in the browser and go out as
`POST /api/v1/journeys/{journeyId}/capture`, ten per batch by default.
4. **Finish.** The `complete` event flushes at once. The SDK also tries an
exit-safe flush on `visibilitychange` and `pagehide`.
The queue survives a reload. It holds up to 100 events for 24 hours, keeps
the session, revision, and context each event was created with, and retries
a failed batch later. Every event carries an ID, so a duplicate delivery is
counted once. A `429` is obeyed: the SDK waits out `Retry-After` before it
tries again.
`baseUrl` exists for local testing only. The web SDK honors a localhost
value and falls back to `https://app.getfounderhq.com` for anything else.
## Tell FounderHQ who the person is
If your app already knows the person, pass `identity`. FounderHQ uses it to
link the response to a contact.
```tsx
```
Any non-empty `identity` field wins over an email, phone, or ID the person
typed into the Journey. Their typed answers are still saved in the
response. Pass only the fields you actually know.
## React when the Journey finishes
`onEvent` fires as the person moves through the flow. Use the `complete`
event to send them onward.
```tsx
{
if (event.type === "complete") {
window.location.href = "/dashboard";
}
}}
/>
```
Event types: `session_start`, `step_view`, `step_submit`, `navigate`,
`complete`, and `purchase_intent`. Each event carries `answers` (what the
person entered) and `computedVariables` (values derived from those
answers), kept separate.
## Verify
1. Load the page in your app. The first screen of your Journey renders.
2. Answer a screen and continue. Reload the page — you come back to the
same screen with your answer still filled in.
3. Finish the flow, then open the Journey in FounderHQ. The response is
listed, and it carries the contact you passed in `identity`.
## Props
| Prop | What it does |
| --- | --- |
| `apiKey` | Your publishable key. Required. |
| `journeyId` | The Journey to render. Required. |
| `config` | Render a config you already have instead of fetching it. Access is still checked with FounderHQ. |
| `storageKey` | Where answers and progress are saved in the browser. |
| `identity` | `externalId`, `email`, `phone` for contact linking. |
| `initialAnswers` | Seed answers at first render, for data only your app knows (live pricing plans, for example). |
| `initialOptions` | Replace an option list at render time, keyed by the answer variable. |
| `onEvent` | Called for every Journey event. |
| `onOpenURL` | Handle link buttons yourself instead of letting the browser navigate. |
| `onDiscountCodeApply` | Validate a discount code and return new pricing. Your app owns the rules. |
| `capture` | `false` turns off sending events to FounderHQ. An object adds custom context. |
| `theme` | CSS class(es) for the Journey root, overriding the theme set in FounderHQ. |
| `className` | Extra class on the Journey root. |
| `presentation` | `"web"` (default) or `"native"` for an edge-to-edge app WebView. |
| `loadingComponent` | What to show while the config loads. |
| `errorComponent` | What to show when the config cannot load. |
| `baseUrl` | Local testing override. Only a localhost value is honored. |
## Injecting data your app owns
Some screens need values that live in your app, not in the Journey config —
live prices from StoreKit or your own API, or a country list. Pass them in
`initialAnswers` and reference them from the Journey config with a
template.
```tsx
```
Keys you declared as computed variables are ignored here. Their formulas
always win.
## Good practice
- **One key per brand.** A Journey embed key only reads Journeys in the
brand it was created for. Do not carry one key to a second brand.
- **List every origin you serve from.** Staging and preview domains are
separate origins. A missing one fails with `403`.
- **Test against localhost.** Run FounderHQ locally and pass
`baseUrl="http://localhost:3000"`. Any other override is ignored.
- **Give each Journey its own `storageKey`.** Two Journeys sharing one key
restore each other's answers.
- **Leave capture on.** Turning it off with `capture={false}` also removes
responses and funnel analytics for that embed.
See [Limits and reliability](/journeys#limits-and-reliability) for the
batch, body, and retry rules the SDK already follows for you.
## Troubleshoot
### The Journey shows "This Journey is unavailable"
The access check or the config fetch failed. Check that the Journey is
published, that the Journey ID matches, that the publishable key belongs to
the same brand as the Journey, and that the page's origin is on the key's
allowed-origin list.
### The Journey renders with the wrong fonts
Import `@founderhq/journeys/styles.css`. Without it the Journey inherits
your page's fonts and loses its own layout rules.
### Answers come back with no contact attached
Pass `identity` with at least one of `externalId`, `email`, or `phone`.
Without it, FounderHQ can only link a contact when the person types an
email or phone into the Journey itself.
# Journeys (https://www.getfounderhq.com/docs/journeys)
A Journey is a guided flow you put in front of a person: onboarding, a
quiz, a survey, a pricing screen. You build it inside FounderHQ. You render
it inside your own product with the FounderHQ SDK, or
[share it without code](/journeys/share) as a public link, an embed, or a
QR code on your phone.
## What you get
- **One flow, every platform.** The same published Journey renders on the
web, in Expo and React Native, in SwiftUI and UIKit, and in Android Views
and Jetpack Compose.
- **Changes without a release.** The SDK fetches the published config at
render time. You edit the flow in FounderHQ and the next render shows it.
No app-store submission.
- **Answers tied to a contact.** Your app tells the SDK who the person is.
FounderHQ links the response to that contact.
- **Branching without code.** Routing rules pick the next screen from the
answers so far. Computed variables derive new values from those answers.
## How it fits together
1. You build and publish the Journey in FounderHQ.
2. You copy the Journey's ID and a publishable key.
3. You render `` in your app with those two values.
4. Responses and step events show up back in FounderHQ.
## Keys
A Journey renders with a publishable key (`fhq_pk_XXXX`). The key belongs
to one brand. It is safe in browsers and in shipped mobile apps: every
config fetch, every access check, and every captured event is validated on
FounderHQ's servers. Never put a secret key (`fhq_sk_XXXX`) in a Journey.
Create the key in FounderHQ under **Settings → API keys** and pick the
**Journey embed** preset. It gives the key exactly two permissions:
| Permission | What it allows |
| --- | --- |
| `journeys:read_published` | Read the published config of a Journey in the key's brand. |
| `journeys:capture` | Record step events, answers, and completions. |
A Journey embed key needs at least one allowed origin. FounderHQ refuses to
create it without one, and refuses a request whose `Origin` is not on the
list. Add the origins your pages are served from — scheme, host, and port,
with no path:
```
https://your-domain.com
https://www.your-domain.com
http://localhost:3000
```
Native SDKs send no `Origin` header, so the list does not gate them.
Scope the key to one brand, and to specific Journeys when you can. Do not
reuse one key across brands: a key can only read Journeys in the brand it
was created for, so a shared key fails the moment you add a second brand.
## Where the SDKs connect
Every Journeys SDK talks to `https://app.getfounderhq.com`, the FounderHQ
app host. Analytics use a separate ingest host. You do not configure the
production Journey host.
Every SDK takes a base-URL override for local development, and each one
guards it:
| SDK | Option | What it accepts |
| --- | --- | --- |
| `@founderhq/journeys` (web) | `baseUrl` | A localhost address only. Every other value silently falls back to `https://app.getfounderhq.com`. |
| React Native, iOS, Android | `baseUrl` / `baseURL` | Any HTTPS origin. Plain `http://` only for a local host, and an invalid value throws. |
On the web that fallback means a mistyped `baseUrl` cannot quietly send
responses somewhere else. It also means you cannot point a web embed at a
proxy of your own.
## Limits and reliability
| Rule | Value |
| --- | --- |
| Events per capture batch | 50. A larger batch is refused with `400`. |
| Request body | Keep it under 256 KiB. |
| Over budget | `429` with a `Retry-After` header, in whole seconds. |
Budgets are counted per organization. Brands, keys, Journeys, and app
installations all spend from the same allowance, so adding a key does not
add capacity.
If you write your own client instead of using an SDK:
- Honor `Retry-After` on a `429`. Do not retry sooner, and do not retry in
a tight loop.
- Give every event a stable `id` and reuse it on a retry. FounderHQ
de-duplicates by event ID, so a replayed batch is safe.
- Send one `clientSessionId` for the whole presentation, and keep it on
events you deliver late.
- You may send an `X-FounderHQ-Installation-Id` header on the load calls
(`prepare`, the published-config `GET`, and `validate`). It identifies one
app installation for per-client fairness. It is optional, allowed through
CORS, and no shipped SDK sends it yet.
## Start here
- [Embed on the web](/journeys/embed-on-the-web) — install the
package and render your first Journey.
- [Mobile (RN / iOS / Android)](/journeys/mobile) — the native SDKs
and what each one exposes.
- [Steps and blocks reference](/journeys/steps-and-blocks-reference) —
every screen type and every block you can place on an info page.
# Instant Journey presentation on Android (https://www.getfounderhq.com/docs/journeys/mobile-preparation-android)
Prepare a published Journey while the preceding screen is visible, then present
it without waiting for a configuration request or a new renderer. FounderHQ
retains one renderer for each host and keeps preparation free of impressions,
answers, and completion events.
Install version **0.8.1** or later. Preparation arrived in 0.7.0. Existing
`load(...)` calls keep working: they prepare and present in one operation.
Adopt the persistent host APIs below when presentation latency matters.
## Install
Add `mavenCentral()` to your dependency repositories, then install the core
View SDK and, for Compose apps, its adapter:
```kotlin
implementation("com.getfounderhq:journeys:0.8.1")
implementation("com.getfounderhq:journeys-compose:0.8.1")
```
The SDK supports Android API 24 and later.
## Jetpack Compose
Place one `JourneyHost` at a stable point in the composition. Keep it composed
while navigation changes the surrounding screen.
```kotlin
@Composable
fun OnboardingEntry() {
val journeyState = rememberJourneyState()
Box(Modifier.fillMaxSize()) {
Button(onClick = journeyState::present) {
Text("Start onboarding")
}
JourneyHost(
configuration = JourneyConfiguration(
apiKey = "fhq_pk_XXXX",
journeyId = "journey_123",
identity = JourneyIdentity(externalId = "usr_1042"),
),
state = journeyState,
modifier = Modifier.fillMaxSize(),
listener = object : JourneyListener {
override fun onEvent(event: JourneyEvent) {
if (event.type == JourneyEventType.COMPLETE) {
journeyState.dismiss()
}
}
},
)
}
}
```
`JourneyHost` begins preparation when it enters the composition. Observe
`journeyState.readiness` or `journeyState.isPrepared` when the surrounding UI
needs to reflect readiness. `present()` shows the prepared renderer. `dismiss()`
hides it and resets that same renderer with a fresh client session, so the next
presentation does not show completed steps or previous answers.
Call `journeyState.prepare()` to retry a failed preparation or ask the retained
host to refresh. Call `journeyState.dispose()` only when the owning flow is
finished; otherwise keep the host composed and use `dismiss()`.
## Android Views
Keep one `JourneyView` attached to the host Activity or Fragment and use the
same lifecycle directly:
```kotlin
val journeyView = JourneyView(requireContext())
journeyView.prepare(
JourneyConfiguration(
apiKey = "fhq_pk_XXXX",
journeyId = "journey_123",
identity = JourneyIdentity(externalId = "usr_1042"),
),
listener,
)
startButton.setOnClickListener {
journeyView.present()
}
closeButton.setOnClickListener {
journeyView.dismiss()
}
```
Call `dispose()` when the host is permanently destroyed. The View also cleans
up with its lifecycle owner and releases a hidden prepared renderer under
memory pressure. Changing identity invalidates the renderer before another
Journey can start.
## Readiness, refresh, and retries
Preparation fetches the published configuration while the shared renderer
loads in parallel. The server controls the refresh interval, which the SDK
clamps to 10–30 minutes. A successfully prepared configuration can start for up
to 30 minutes. A failed refresh does not extend that window; the SDK retries
only after its backoff when the user presents again, explicitly retries, or the
app next enters the foreground. It does not run a repeated network retry loop.
HTTP 429 responses honor `Retry-After`, and manual retry stays disabled until
that delay has passed.
The configuration and revision stay fixed while a Journey is visible. A newly
published revision becomes the next prepared presentation after the active one
is dismissed. Capture batches keep the revision, client session, identity, and
queue metadata assigned when each event was created, including batches sent
after an app restart.
An authorization denial immediately ends interaction, discards prepared state,
and suppresses automatic requests. **Try again** or an explicit `prepare()` / `present()`
performs a fresh authorization request with the same configuration, so a
republished Journey or restored entitlement can recover without rebuilding its
host.
Renderers that do not advertise preparation support use the compatible direct
path. They initialize only after `present()` and never emit hidden analytics.
## Loading and error UI
The default presentation shows an accessible, theme-aware progress ring without
text. Reduced motion uses a static ring. If the Journey is not rendered within
15 seconds of foreground time, the user sees a restrained status icon,
**Unable to load. Please try again.**, and an explicit **Try again** button. The
button stays disabled only while a request is active or a server-directed
backoff is pending.
View apps can replace both surfaces:
```kotlin
journeyView.loadingViewFactory = { context -> MyLoadingView(context) }
journeyView.errorViewFactory = { context, error, retry ->
MyJourneyErrorView(context).apply {
setRetryAction(retry)
}
}
```
Compose apps set these factories through `JourneyHost(configureView = { ... })`.
Keep custom loading and retry controls accessible, and call the supplied retry
function instead of creating a second host.
# Prepare Journeys on iOS (https://www.getfounderhq.com/docs/journeys/mobile-preparation-ios)
`FounderHQJourneys` can prepare a published Journey before the user opens it.
Preparation loads the Journey configuration and its renderer in parallel, then
keeps one hidden `WKWebView` ready for presentation.
Install `FounderHQJourneys` 0.8.0 or later. You need a published Journey, its
Journey ID, and a publishable key (`fhq_pk_XXXX`).
## Prepare from SwiftUI
Create one `JourneyHost` for the screen or flow that presents the Journey. Keep
that host alive while the user can open, close, and reopen the Journey.
```swift
import FounderHQJourneys
import SwiftUI
struct OnboardingEntryView: View {
@State private var showJourney = false
@StateObject private var host = JourneyHost(configuration: .init(
apiKey: "fhq_pk_XXXX",
journeyID: "journey_123",
identity: JourneyIdentity(externalID: "usr_1042")
))
var body: some View {
Button("Start onboarding") {
showJourney = true
}
.task {
try? await host.prepare()
}
.fullScreenCover(isPresented: $showJourney) {
JourneyView(host: host)
}
}
}
```
`prepare()` is safe to call more than once. Concurrent calls share the same
work, and a fresh preparation returns immediately. `JourneyView(host:)` calls
`present()` when it appears and `dismiss()` when it leaves.
For UIKit, keep the same host in the owning view controller or coordinator:
```swift
let host = JourneyHost(configuration: configuration)
Task { try? await host.prepare() }
let journey = JourneyViewController(host: host)
present(journey, animated: true)
```
You can also call `try await host.present()` yourself when managing the host's
view directly. Observe `host.readiness` and `host.isPresented` when native UI
needs to reflect its state.
## Prepared and direct presentation
A current renderer advertises preparation and visibility support. The SDK
initializes it while hidden and waits until the first screen's essential layout
and assets have rendered. Hidden preparation does not start presentation
analytics. Showing the Journey reuses that same `WKWebView`.
An older renderer that does not advertise those capabilities uses direct
presentation. The SDK still fetches its configuration ahead of time, but waits
to initialize the renderer until the Journey is visible. This avoids recording
a hidden visit and keeps older apps working during a renderer rollout.
Each new presentation receives a new client session. After dismissal, a capable
renderer resets the flow while hidden so reopening starts at the first screen
without allocating another web view.
## Loading, errors, and retries
The built-in loading state is a text-free progress indicator with an accessible
label. It avoids animation when Reduce Motion is enabled. Presentation has a
15-second deadline; the built-in error state gives the user a generic message
and a **Try again** action. A retry attempts preparation again.
Pass custom SwiftUI views when your app needs its own treatment:
```swift
JourneyView(
host: host,
loadingView: AnyView(MyJourneyLoader()),
errorView: { error, retry in
AnyView(MyJourneyError(onRetry: retry))
}
)
```
Avoid showing `error.localizedDescription` directly in customer-facing UI. It
may contain network or renderer details. Send the error to your logging system
and keep the visible message focused on retrying or leaving the flow.
## Freshness and offline starts
FounderHQ returns a refresh interval, which the SDK clamps to 10–30 minutes.
While foregrounded, the host schedules one refresh when due, whether prepared
or active. A successful refresh during an active Journey stages the next
configuration without changing the displayed content or answers.
For the first 10 minutes, a ready preparation opens immediately. Between its
refresh time and 30 minutes, it still opens immediately and refreshes
asynchronously. Content older than 30 minutes requires a successful fetch
before a new presentation. An active Journey is not automatically ended when
its original preparation passes that age.
Only successful configuration responses, or authenticated unchanged responses
matching cached JSON, renew freshness. Failures do not renew it or schedule an
automatic retry loop. Presenting again, resuming, or explicitly retrying can
make another attempt after backoff. Requests coalesce and honor `Retry-After`.
A definitive 401, 403, or confirmed Journey 404 discards prepared content and
stops active interaction. **Try again**, `prepare()`, or `present()` performs
fresh authorization with the same configuration; it never reuses denied
content. Downloaded content can remain visible offline until the device
receives a denial. Restarting the app requires fresh authorization for new
presentations, independently of pending answer delivery.
## Identity, authorization, and capture
The publishable key authorizes displaying the Journey. `JourneyIdentity`
controls who the response is attributed to; it does not grant access. When the
signed-in person, publishable key, Journey ID, base URL, or other configuration
changes, call:
```swift
host.updateConfiguration(updatedConfiguration)
```
The host invalidates the prepared content and prepares the new scope. This
prevents a Journey prepared for one person from being shown to another.
Capture is enabled by default and is separate from display authorization. Set
`capture: nil` to disable it. Capture request bodies pass through the native SDK
unchanged. Every queued event keeps the visitor, presentation session, Journey
revision, and context recorded when the event was created. If an answer is sent
later, including after an app restart or identity change, it remains attached
to that original immutable revision and session.
Publishing while a Journey is open does not replace its active content. The
next successful preparation can use the new revision; the open presentation
and its delayed answers stay tied to the revision that the user saw.
## Dispose of a host
Call `dispose()` when the owning flow is permanently finished:
```swift
host.dispose()
```
Disposal cancels preparation and refresh work, removes lifecycle and bridge
handlers, stops the renderer, and makes the host unavailable for future
presentation. Use `dismiss()` when the Journey may be opened again. Memory
warnings unload hidden renderer content while preserving an active Journey.
An unloaded preparation can rebuild its renderer within the same freshness
window.
## Upgrade from an earlier version
Update the Swift Package Manager dependency to 0.8.0 or the CocoaPods entry to:
```ruby
pod 'FounderHQJourneys', '~> 0.8.0'
```
Existing `JourneyView(configuration:...)` and `JourneyViewController`
initializers remain supported. They create and manage a host for that view, so
an existing integration can upgrade without changing its presentation code.
To gain preparation and repeated presentation, move the configuration and
callbacks into a long-lived `JourneyHost`, call `prepare()` before the launch
action, and pass the host to `JourneyView(host:)` or
`JourneyViewController(host:)`. Keep one host per presentation surface and call
`updateConfiguration(_:)` whenever its identity or Journey scope changes.
# Prepare Journeys in React Native (https://www.getfounderhq.com/docs/journeys/mobile-preparation-react-native)
`JourneyHost` prepares a Journey before the person opens it. It downloads the
published configuration while one persistent renderer shell starts in parallel,
then reveals that same WebView when you call `present`.
Install `@founderhq/journeys-react-native` 0.8.1 or later. Preparation
arrived in 0.7.0; `JourneyView` keeps its existing API.
## Install
```sh
npx expo install react-native-webview react-native-safe-area-context expo-haptics
npm install @founderhq/journeys-react-native@^0.8.1
```
Bare React Native apps need `react-native-webview` and `react-native-safe-area-context` and import from the package
root. On Android, also allow Journey haptic feedback in
`android/app/src/main/AndroidManifest.xml`:
```xml
```
Expo apps import from `/expo` to get Expo haptics by default. Expo normally
adds the Android vibration permission through native manifest merging.
## Mount one persistent host
Mount `JourneyHost` once beside your navigator. Keep it in the tree while the
app is running. It is a normal React Native view, not a modal, so it inherits
the size of its parent and does not create a second window.
```tsx
import {
JourneyHost,
type JourneyHostRef,
} from "@founderhq/journeys-react-native/expo";
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const onboarding = {
journeyId: "journey_123",
identity: { externalId: "usr_1042" },
storageKey: "onboarding",
};
export function App() {
const journeys = useRef(null);
const insets = useSafeAreaInsets();
async function prepareOnboarding() {
await journeys.current?.prepare(onboarding);
}
async function openOnboarding() {
// This also prepares when prepareOnboarding has not run yet.
await journeys.current?.present(onboarding);
}
return (
{
if (event.type === "complete") {
journeys.current?.dismiss();
}
}}
>
journeys.current?.dismiss()}
style={{ position: "absolute", right: 12, top: insets.top + 8 }}
>
Close
);
}
```
The parent must have a real size, normally `flex: 1`. The host fills that parent.
Your app continues to own status-bar, navigation-bar, and safe-area policy. Pass
native controls as `JourneyHost` children when they must appear above the
Journey, and inset those controls with `react-native-safe-area-context`.
Do not mount a second `JourneyHost` or a separate `JourneyView` at the same
time. One persistent host owns one renderer WebView.
## Prepare at the useful moment
Call `prepare` when intent becomes likely: after sign-in, when the preceding
screen opens, or when the person enters the part of the app that can launch the
Journey. Preparation is safe to repeat. Concurrent calls for the same inputs
share one request.
```tsx
const prepared = await journeys.current?.prepare({
journeyId: "journey_123",
identity: {
externalId: account.id,
email: account.email,
},
initialAnswers: { company_size: account.companySize },
capture: {
context: { source: "mobile-onboarding" },
},
});
console.log(prepared?.revisionId, prepared?.refreshAt);
```
`prepare` resolves when the Journey is ready. `present` resolves when it is
visible; completion and purchase intents arrive through `onEvent`, so there is
no presentation promise waiting for the Journey to finish.
After `dismiss`, the host keeps the downloaded configuration and prepares a
fresh client session in the same WebView. Answers and navigation from the last
presentation are not reused unless your own `storageKey` policy restores them.
## Freshness and publication changes
A successful preparation is fresh for at least 10 minutes. FounderHQ returns a
refresh interval that the SDK clamps to 10–30 minutes. The host schedules a
refresh while foregrounded, including during an active Journey, and checks
again when the app resumes.
A ready preparation opens immediately through 30 minutes after its last
successful fetch. When refresh is due, presentation happens first and the
refresh runs asynchronously. Content older than 30 minutes requires a
successful fetch before a new presentation. The displayed configuration and
answers remain stable during an active Journey, even beyond that window;
updated content is staged for the next presentation.
Only validated configuration or an authenticated unchanged response matching
cached JSON renews freshness. Failed requests do not renew it or create an
automatic retry loop. Present, resume, and explicit retry can try again after
backoff. Requests coalesce and honor `Retry-After`. Loading has a cumulative
15-second foreground deadline; time in the background does not consume it.
A definitive 401, 403, or confirmed Journey 404 discards preparations and stops
active interaction. Explicit prepare, present, or **Try again** can freshly
authorize the same inputs after access is restored. Cached content is never
reused after a denial. Downloaded content may remain displayed offline until
the device receives a denial. Restarting the app requires fresh authorization
for new presentations while pending answers retain their original attribution.
## Input and account changes
The host invalidates preparation when any of these values change:
- API key, FounderHQ base URL, or renderer URL
- Journey ID or local test configuration
- identity, initial answers, or initial options
- capture settings or capture callbacks
- storage key or theme
Changing the host API key or URL disposes the old credential scope
automatically. Call `dispose` when the signed-in account changes, even when two
accounts happen to use the same key and Journey ID.
```tsx
await auth.signOut();
journeys.current?.dispose();
```
`dispose` drops the cached preparation and renderer content. The SDK also
disposes an unused renderer on an OS memory warning. A later `prepare` or `present(input)` starts a
new renderer shell.
Use 0.7.1 or later for immediate `dispose(); present(input)` or
`dispose(); prepare(input)` calls. It recreates the renderer shell even when
React batches disposal and the next request into one update.
## Capture after dismissal or restart
Capture batches can finish after a Journey is dismissed or a new session is
prepared. Every batch keeps the session, revision, visitor, identity, and
context recorded when its events occurred. The native host forwards that body
unchanged; it never relabels delayed events with the current presentation.
Failed batches remain eligible for replay across a renderer restart. A custom
capture transport receives the same immutable request body:
```tsx
await journeys.current?.prepare({
journeyId: "journey_123",
capture: {
transport: async ({ url, method, body }) => {
const response = await yourNetworkClient.request({ url, method, body });
return response.ok;
},
},
});
```
## Retry and customize the host
The default loading state is an accessible theme-colored ring with no status
copy. It respects the device's reduced-motion setting. The default error state
uses a short generic message and a retry button.
Use `indicatorColor` to match your app. Use `errorComponent` when you need your
own error surface; its retry callback follows the same 15-second deadline and
freshness rules.
```tsx
(
)}
onStatusChange={(status) => {
// idle, preparing, prepared, presenting, visible, error, or blocked
}}
/>
```
You can also provide `onOpenURL`, `onDiscountCodeApply`, `haptics`, and
`onError`. The ref retains `goNext`, `goBack`, `goToStep`, `setAnswer`, and
`flushCapture` for native controls.
## Keep the direct view when preparation is unnecessary
`JourneyView` still works for a screen that naturally mounts before the person
expects content. It validates access, fetches the published configuration, and
renders directly:
```tsx
import { JourneyView } from "@founderhq/journeys-react-native/expo";
```
The persistent host also falls back safely when it encounters a renderer from
before preparation support. It fetches and authorizes the configuration ahead
of time, but does not initialize a hidden Journey. `present` initializes it as
visible, which avoids hidden analytics while preserving compatibility.
# Mobile (RN / iOS / Android) (https://www.getfounderhq.com/docs/journeys/mobile)
Render a published Journey inside your Expo, React Native, iOS, or Android
app, with the same screens your web embed shows.
You need a published Journey in FounderHQ, its Journey ID, and a
publishable key (`fhq_pk_XXXX`). See [Journeys](/journeys) for how
keys work, and [Embed on the web](/journeys/embed-on-the-web) for the
props the native SDKs mirror.
## How the native SDKs work
Every platform SDK hosts the same Journey renderer and wraps it in a native
view. Config fetching and event capture stay in the platform's own
networking layer. Your app never puts a FounderHQ key in a URL.
Because the renderer is shared, a screen you publish once looks and behaves
the same on web and on device. Haptics run natively, not inside the web
view. Links are handed to your app to route.
Every SDK connects to `https://app.getfounderhq.com` by default and calls
`/api/v1/journeys/{journeyId}` for preparation, the published config,
capture, and completion. Set `baseUrl` (`baseURL` on iOS) only to point at
a local FounderHQ while you develop. The native SDKs accept any HTTPS
origin there and reject plain `http://` unless the host is local, so a bad
value fails loudly instead of leaking responses.
Native apps send no `Origin` header, so a key's allowed-origin list does
not gate them. It is still required when you create the key.
## Prepare before presenting
For an immediate opening, prepare the first screen while your app is visible,
then present it when the user chooses to continue. Preparation does not count
as a Journey view or start animations, media, or haptics. Each host retains one
web view and checks for updates while foregrounded.
- [Expo and React Native preparation](/journeys/mobile-preparation-react-native) — `@founderhq/journeys-react-native` 0.7.0 and later.
- [SwiftUI and UIKit preparation](/journeys/mobile-preparation-ios) — `FounderHQJourneys` 0.7.0 and later.
- [Android View and Compose preparation](/journeys/mobile-preparation-android) — Journeys 0.7.0 and later.
The direct `JourneyView` integrations below remain supported. For custom
clients, see the [preparation API](/journeys/api-reference/prepareJourney)
and [Limits and reliability](/journeys#limits-and-reliability).
## Expo and React Native
```sh
npx expo install react-native-webview react-native-safe-area-context expo-haptics
npm install @founderhq/journeys-react-native@^0.8.1
```
`react-native-webview` ships with Expo Go, so this SDK needs no custom
native module.
```tsx
import {
JourneyView,
type JourneyViewRef,
} from "@founderhq/journeys-react-native/expo";
import { useRef } from "react";
export function OnboardingJourney() {
const journey = useRef(null);
return (
{
if (event.type === "complete") {
// Continue into the app.
}
}}
onOpenURL={(url) => {
// Route through Expo Router or Linking.
}}
style={{ flex: 1 }}
/>
);
}
```
Bare React Native imports `JourneyView` from the package root instead of
`/expo`.
Haptics differ by entry point. The Expo entry point maps every Journey
haptic to `expo-haptics`. The bare entry point uses React Native's
`Vibration` module, and you can pass `haptics={(type) => ...}` to route it
to your own haptics library without changing the Journey.
## iOS
`FounderHQJourneys` is a Swift Package for SwiftUI and UIKit. It supports
iOS 15 and later.
```swift
import FounderHQJourneys
struct OnboardingView: View {
private let controller = JourneyController()
var body: some View {
JourneyView(
configuration: JourneyConfiguration(
apiKey: "fhq_pk_XXXX",
journeyID: "journey_123",
identity: JourneyIdentity(externalID: "usr_1042")
),
controller: controller,
onEvent: { event in
if event.type == .complete {
// Continue into the app.
}
}
)
}
}
```
UIKit apps present `JourneyViewController` instead. `JourneyController`
publishes `canGoBack`, `currentStepID`, and `currentStepIndex`. Both entry
points accept your own loading and error views.
In Xcode, add `https://github.com/FounderHQ/founderhq-journeys-ios` using
Swift Package Manager, version **0.8.0** or later.
CocoaPods consumers can use:
```ruby
pod 'FounderHQJourneys', '~> 0.8.0'
```
Or install directly from the Git release:
```ruby
pod 'FounderHQJourneys', :git => 'https://github.com/FounderHQ/founderhq-journeys-ios.git', :tag => 'v0.8.0'
```
## Android
The Android SDK is a core View artifact plus an optional Jetpack Compose
adapter. It supports Android API 24 and later.
```kotlin
val journey = JourneyView(context)
journey.load(
JourneyConfiguration(
apiKey = "fhq_pk_XXXX",
journeyId = "journey_123",
identity = JourneyIdentity(externalId = "usr_1042"),
),
object : JourneyListener {
override fun onEvent(event: JourneyEvent) {
if (event.type == JourneyEventType.COMPLETE) {
// Continue into the native app.
}
}
},
)
```
Compose apps use `com.founderhq.journeys.compose.JourneyView` and configure
the underlying View with `configureView`. The core View exposes
`loadingViewFactory`, `errorViewFactory`, `hapticHandler`,
`handleBackPressed()`, and the current navigation state.
Add `mavenCentral()` to your repositories and install:
```kotlin
implementation("com.getfounderhq:journeys:0.8.1")
// Optional Jetpack Compose integration:
implementation("com.getfounderhq:journeys-compose:0.8.1")
```
## What every platform gives you
| Capability | Expo / RN | iOS | Android |
| --- | --- | --- | --- |
| `goNext`, `goBack`, `goToStep`, `setAnswer`, `flushCapture`, `reload` | Yes | Yes | Yes |
| Typed events and discount callbacks | Yes | Yes | Yes |
| Native haptics | Yes | Yes | Yes |
| First-paint loading, custom loading and error views | Yes | Yes | Yes |
| Capture flushing on app lifecycle changes | Yes | Yes | Yes |
| Deep links and external links handed to your app | Yes | Yes | Yes |
| Local test configs and custom capture transports | Yes | Yes | Yes |
| Hardware Back handled | Yes | n/a | Yes |
## Verify
1. Run the app and open the screen that hosts the Journey. The first
screen renders inside your native view.
2. Answer a screen and continue. The native back gesture or button walks
you back one screen, not out of the flow.
3. Finish the flow, then open the Journey in FounderHQ. The response is
listed with the contact you passed in `identity`.
## A note on `@founderhq/journeys-bridge`
`@founderhq/journeys-bridge` holds the message types the renderer and the
native hosts speak. Install a platform SDK instead. You only need the
bridge package if you are building a host of your own.
# Share without code (https://www.getfounderhq.com/docs/journeys/share)
Three ways to open a Journey that need no code. All three live on
`https://journeys.getfounderhq.com` and are set from the **Share** button in
the Journey editor.
| Surface | URL | Shows | Who can open it |
| --- | --- | --- | --- |
| Public link | `journeys.getfounderhq.com/` | the published version | anyone with the link, while sharing is on |
| Embed | `journeys.getfounderhq.com/embed/` in an iframe | the published version | any website, while sharing is on |
| Phone preview | `journeys.getfounderhq.com/p/` | your latest saved version | anyone with the link, for 24 hours |
## Published means published
Saving a Journey stores a draft. Only **Publish to Live** changes what people
see: through the SDK, the public link, and the embed. Your version history
marks the live version and the latest saved one.
## Preview on your phone
Open **Share** in the editor and scan the QR code. The preview shows the last
version you saved, published or not, so you can test a flow before it goes
live. It records nothing: no responses, no analytics. Reload the page on your
phone after each save to see the change.
Preview links expire after 24 hours. Scan again for a new one.
Shared a preview link with someone and want it dead sooner? Hover the QR code
and press **Regenerate**. Every earlier preview link for that Journey stops
working at once, and you get a fresh code.
## Public link
Turn on **Anyone with the link can open this Journey**. The Journey must be
published first. Turn the switch off at any time to close the link; open pages
stop recording within minutes.
The page is not indexed by search engines. Responses and events land in the
same place as SDK captures, tagged with the `hosted-link` surface.
## Embed on a website
Copy the snippet from **Share** and paste it where the Journey should appear.
```html
```
The iframe is the Journey's viewport, so pick a size that fits your page. The
same switch that controls the public link controls the embed. Captures are
tagged with the `hosted-embed` surface and the origin of the page that frames
them.
### Inside a drawn device frame
If your page draws a phone frame around the iframe, add
`?safeArea=top,right,bottom,left` (pixels) to the embed URL. The Journey keeps
its controls clear of those edges while its background still fills the frame.
```html
```
### Messages from the embed
The embed posts a small set of messages to the page that frames it, so your
page can react (close a modal, show a thank-you). Listen with
`window.addEventListener("message", …)` and check `event.data.source`.
```ts
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || data.source !== "founderhq-journeys" || data.version !== 1) return;
if (data.event === "complete") {
// data.journeyId — the Journey that finished
}
});
```
| `event` | Extra fields | When |
| --- | --- | --- |
| `ready` | — | the first screen rendered |
| `step` | `stepId`, `index` | the visible step changed |
| `complete` | — | the person finished the Journey |
Messages carry no answers, identity, or URLs. Links inside the Journey open in
a new tab from the iframe itself.
## Prefer code?
The [web SDK](/journeys/embed-on-the-web) and the [mobile SDKs](/journeys/mobile)
give you a publishable key, identity linking, and full control of the page.
# Steps and blocks reference (https://www.getfounderhq.com/docs/journeys/steps-and-blocks-reference)
Every Journey is a list of screens. Each screen has a type. One type,
`info_page`, is built from blocks — the smaller pieces you stack to make a
screen say what you want.
## Screen types
Each screen stores its answer under the screen's variable.
| Type | What the person does | Answer shape |
| --- | --- | --- |
| `single_select` | Picks one option from a list. Can advance on tap. | The option ID |
| `multi_select` | Picks any number of options from a list. | A list of option IDs |
| `input` | Fills one or more fields. Each field is text, email, number, or tel. | A value per field |
| `slider` | Drags a slider between a minimum and a maximum. | A number |
| `swipe_cards` | Swipes through a stack of cards, yes or no on each. | `yes` or `no` per card |
| `counter_select` | Sets a count on each option with plus and minus. | A count per option |
| `info_page` | Reads. No question — this screen is made of blocks. | None |
Every screen also supports a heading, a description, validation, a
background, and routing rules that pick the next screen from the answers so
far.
## Blocks
Blocks fill an `info_page`. Three of them collect an answer:
`single_select`, `multi_select`, and `pricing_plans`. The rest present
content.
### Text and layout
| Block | What it shows |
| --- | --- |
| `heading` | Title or heading text |
| `text` | Body text paragraph |
| `decorated_text` | Rich text with an optional image on the left or right |
| `list` | Bulleted or numbered list |
| `quote` | Quote card with author, role, and optional star rating |
| `callout` | Highlighted info, warning, success, or tip box |
| `card` | Styled container with icon, title, and description |
| `columns` | Side-by-side layout holding other blocks |
| `spacer` | Vertical space between blocks |
| `divider` | Horizontal line separator |
| `floating_label` | Small label that pins to the top while the page scrolls |
| `badge` | Small label pill |
| `icon` | Icon or emoji |
### Media
| Block | What it shows |
| --- | --- |
| `image` | Image or illustration |
| `video` | HTML5 video, or a YouTube or Vimeo embed |
| `lottie` | Animated illustration from a Lottie file |
| `carousel` | Swipeable slide deck |
| `device_frame` | Phone mockup wrapping a screenshot or a video |
| `avatar_group` | Overlapping row of avatars with an optional overflow count |
### Numbers and charts
| Block | What it shows |
| --- | --- |
| `stat` | Large number with a label |
| `metric` | Stat card with an animated counter, label, and accent |
| `counter` | Number that counts up from zero |
| `progress_bar` | Horizontal progress indicator |
| `circular_progress` | Circular progress indicator with multiple segments |
| `comparison_bar` | Vertical bar chart for comparisons |
| `line_chart` | Line chart with one or more series |
| `table` | Comparison table with check, cross, lock, warning, icon, or text cells |
| `gravity_bin` | Icons that fall and pile up with simulated gravity |
### Sequence and motion
| Block | What it shows |
| --- | --- |
| `timeline` | Vertical timeline with a connecting line and content nodes |
| `checklist` | Checkbox list that ticks itself in sequence |
| `accordion` | Expandable FAQ-style sections |
| `feature_row` | Icon on the left, title and text on the right |
| `before_after` | Split, stacked, or swipeable comparison of two states |
| `notification_stack` | Mock push notifications sliding in one after another |
### Action and answer
| Block | What it does |
| --- | --- |
| `button` | Runs an action: next, back, skip, open a link, start a purchase, or open the discount-code box |
| `single_select` | Pick-one option group; stores the choice in a variable |
| `multi_select` | Pick-many option group; stores the choices in a variable |
| `pricing_plans` | Selectable plans in featured, minimal, or comparison style; the selection drives the `purchase_intent` event |
## Templates
Any text field can reference an answer with `${...}`.
```
You picked ${goal}, so we'll start there.
```
The same syntax reaches into a selected plan:
```
You're subscribing to ${selectedPlan.name} — ${selectedPlan.amount} ${selectedPlan.currency}/${selectedPlan.period}.
```
Available plan paths: `id`, `name`, `amount`, `currency`, `period`,
`originalAmount`, `perUnitLabel`, `display`, `trialDays`, `introOffer`,
`description`, `badge`, `icon`, `features`, and `metadata`.
Templates and computed variables run in a restricted expression language.
It reads properties and array items, does arithmetic and comparisons, and
allows common string and array helpers plus `Math`. It never runs arbitrary
JavaScript, and never runs code supplied through Journey data.
# Custom HTTP integration (https://www.getfounderhq.com/docs/sequences/custom-http-integration)
Send through any provider with an HTTP API, by describing the request
FounderHQ should make. About 20 minutes with the provider's API docs open.
Check [Sequences providers](/sequences) first — if your
provider already ships as a recipe, use that instead and skip this page.
You need the provider's send-endpoint documentation and an API credential.
Then open Sequences in FounderHQ and add an HTTP integration.
## How it works
You define one HTTP integration: a request template plus how to read the
response. You then create a channel connection that points at it and picks
the channel — email, SMS, WhatsApp, or push.
For every message the sequence sends, FounderHQ fills your template with
that message's data, calls your endpoint, and reads the reply to learn
whether it worked.
## Build the request
**Method** — GET, POST, PUT, PATCH, or DELETE.
**URL** — must be a public `http` or `https` address. It can contain
variables.
**Headers and query parameters** — plain key/value pairs. Values can
contain variables.
**Body** — pick one of three modes:
| Mode | What you write | When to use it |
| --- | --- | --- |
| `json` | A JSON structure. Variables run inside string values. | Most JSON APIs |
| `form` | Field names and values. Each value is URL-encoded after it renders. | Form-encoded APIs. You never need escaping filters. |
| `raw` | One string, sent exactly as it renders. | Anything else, or when you need a conditional around whole branches of JSON |
Templating uses Liquid. The `include`, `render`, and `layout` tags are
turned off.
```
{ "to": {{ recipient.phone | json }},
"text": {{ message.text | json }},
"callback": {{ channel.statusCallbackUrl | json }} }
```
The `json` filter is worth using on every value in `raw` mode. It quotes
and escapes the value, so an apostrophe in a message body cannot break your
JSON.
A variable with no value renders as an empty string rather than failing —
with one exception, described under [Guards](#guards).
## Variables you can use
**The person**
| Variable | Value |
| --- | --- |
| `recipient.value` | Whatever address this channel sends to |
| `recipient.email` | Email channel only |
| `recipient.phone` | SMS and WhatsApp channels |
| `recipient.token` | Push channel — the device token |
| `recipient.externalId` | Your own ID for the contact, when the provider resolves people itself |
**The message**
| Variable | Value |
| --- | --- |
| `message.outboundMessageId` | FounderHQ's ID for this send. Pass it through if the provider echoes a reference back. |
| `message.contactId` | The contact this send belongs to |
| `message.subject` | Email subject |
| `message.text` | Plain-text body |
| `message.html` | HTML body |
| `message.title` | Push title |
| `message.body` | Push body |
| `message.templateName` | The approved template name, on template sends |
| `message.senderId` | India DLT sender header, from the SMS template |
| `message.dltTemplateId` | India DLT template ID, from the SMS template |
| `message.smsType` | India DLT category |
| `message.payload` | The whole rendered payload, for anything not listed above |
**The connection**
| Variable | Value |
| --- | --- |
| `channel.type` | EMAIL, SMS, WHATSAPP, or PUSH |
| `channel.providerType` | The provider kind behind this connection |
| `channel.connectionId` | This connection's ID |
| `channel.statusCallbackUrl` | A delivery-report address unique to this connection, with its own token |
**WhatsApp template sends** also get `message.metaTemplate` (the whole
Cloud API template object, for providers that pass Meta's format through),
`message.bodyParamTexts` (the body variables in order), and
`message.bodyParamMap` (the same values keyed by name or position).
## Authenticate
You store exactly one secret per integration. Pick how it is sent:
| Type | What FounderHQ sends |
| --- | --- |
| `none` | Nothing |
| `bearer` | `Authorization: Bearer ` |
| `basic` | `Authorization: Basic ` |
| `custom_header` | A header you name, with an optional literal prefix before the secret — for example `Basic ` for a key you already encoded |
| `body_field` | The secret as a top-level field of the JSON body, under a name you choose |
| `hmac_sha256` | An HMAC-SHA256 of the request body, signed with the secret, in a header you name. Defaults to `x-founderhq-signature` and a `sha256=` prefix. |
The secret is applied after the template renders, so it never appears in
anything FounderHQ stores or shows you.
## Read the response
Point FounderHQ at the fields it should read, using dotted paths like
`data.id` or `messages.0.id`.
| Setting | What it finds |
| --- | --- |
| `idPath` | The provider's message ID. Delivery reports match against it. Falls back to a top-level `id`. |
| `urlPath` | A link to the message at the provider. Falls back to a top-level `url`. |
| `statusPath` | A status value in the body. Falls back to a top-level `status`. |
| `errorPath` | The error text to show you when the send fails. Falls back to `error`, then `message`. |
| `failStatus` | A status value that means failure even on an HTTP 200. Set this for APIs that answer 200 with `{"type":"error"}`. |
FounderHQ retries on 429 and on 5xx responses. Every other failure is
permanent — a bad key or a rejected recipient stops rather than looping.
Requests time out after 15 seconds.
## Delivery reports
`channel.statusCallbackUrl` is a per-connection address carrying its own
token. Two ways to use it:
- Put it in the send request, if the provider takes a callback URL per
message. Nothing to configure at the provider.
- Paste it into the provider's dashboard, if that is where webhooks are
set.
FounderHQ understands the Meta WhatsApp envelope and the delivery-receipt
formats of the SMS providers it ships recipes for. A provider with a shape
of its own can POST FounderHQ's normalized status format instead.
If you wire no callback at all the integration still works. Messages just
stop at Sent, and sequence steps cannot wait on delivery.
## Guards
FounderHQ refuses to send a request it knows is wrong:
- **No recipient.** Your template must reference the right recipient
variable for the channel, and it must have a value. Otherwise the send
fails instead of going out addressed to nobody.
- **Empty regulatory fields.** If your template references an India DLT
variable, that variable must have a value. It is never sent blank. See
[India DLT](/sequences/india-dlt).
- **Session messages the provider cannot take.** A connection marked as
template-only rejects free-text WhatsApp sends outright.
## Verify
1. Use the integration's test send. It shows the exact request FounderHQ
built and the provider's raw reply.
2. The reply's message ID appears in the mapped result. If it reads empty,
fix `idPath`.
3. Build a sequence with one step on this connection and run yourself
through it. The message arrives, and the sequence shows it as Sent.
## Troubleshoot
### The test send says a required variable is missing
Your template references something the test context has no value for.
Check the variable names against the tables above.
### The provider answers 200 but nothing arrives
The API is reporting failure inside a successful response. Set `statusPath`
and `failStatus` so FounderHQ treats it as a failure too.
### Messages send but never reach Delivered
No callback is wired. Either add `channel.statusCallbackUrl` to the send
request, or paste it into the provider's webhook settings.
### The body is malformed when a message contains quotes
You are in `raw` mode without the `json` filter. Write
`{{ message.text | json }}`, not `"{{ message.text }}"`.
# Email (https://www.getfounderhq.com/docs/sequences/email)
Connect your own email provider so sequence email steps send. About 10
minutes, plus DNS propagation.
You need an account with one of the providers below, and a domain you
control. Verify your sending domain at the provider first — see
[Verify your sending domain](#verify-your-sending-domain). Then open
Sequences in FounderHQ and add an email connection.
## Verify your sending domain
Every provider makes you prove you own the domain you send from. You add
DNS records — a DKIM record that signs your mail, and usually an SPF record
that names the provider as an allowed sender — and the provider checks
them.
Do this before you create the connection. FounderHQ sends from the address
you configure, so if the domain is not verified at the provider, the
provider rejects the send.
| Provider | Where to read |
| --- | --- |
| Resend | [resend.com/docs](https://resend.com/docs) → Domains |
| Postmark | [postmarkapp.com/developer](https://postmarkapp.com/developer) → Sender signatures and domains |
| Amazon SES | [docs.aws.amazon.com/ses](https://docs.aws.amazon.com/ses/) → Verified identities |
| SMTP | Your mail host's own instructions |
## Resend
**Paste:** your Resend API key. Create it in the Resend dashboard under API
Keys.
**Set:** the from address, on a domain you verified in Resend. Optionally a
from name and a reply-to address.
**For delivery events:** add a webhook in Resend pointing at the webhook
URL FounderHQ shows on the connection, then paste Resend's signing secret
back into the connection. FounderHQ verifies every webhook against that
secret, so without it delivery events are ignored.
## Postmark
**Paste:** your Postmark server API token. Find it on the server you want
to send from, under API Tokens.
**Set:** the from address, matching a verified sender signature or domain
in Postmark. Optionally a from name, a reply-to address, and a message
stream. FounderHQ uses the `outbound` stream unless you name a different
one.
**For delivery events:** add a webhook in Postmark pointing at the webhook
URL FounderHQ shows on the connection. Postmark lets you attach a custom
header to that webhook — add `X-Webhook-Secret` with the secret you set on
the connection. FounderHQ rejects webhooks whose header does not match.
## Amazon SES
**Paste:** the access key ID and secret access key of an IAM user allowed
to send with SES.
**Set:** the AWS region your SES identity lives in, and the from address,
which must be a verified identity in that same region. Optionally a from
name and a reply-to address.
**For delivery events:** SES publishes them to an SNS topic. Subscribe the
webhook URL FounderHQ shows on the connection to that topic, then record
the topic ARN on the connection. FounderHQ checks the signature on every
SNS message and accepts only the topic you recorded.
A brand-new SES account is in the sandbox and can send only to addresses
you have verified. Request production access in the AWS console before you
run a real sequence.
## SMTP
Use this for a mail host with no API, or for SES SMTP credentials.
**Paste:** the SMTP password.
**Set:** the host, the username, and the from address. The port defaults to
587 with STARTTLS; set it to 465 and turn on implicit TLS if your host
needs that. Optionally a from name and a reply-to address.
SMTP tells you nothing after the handshake. Messages sent this way stay at
Sent — they never move to Delivered. FounderHQ still tracks opens and
clicks itself, because it owns the tracking pixel and rewrites the links.
## Choose where replies go
The connection's reply-to email is the default for every email sent through
it. In an email template, use **Reply to** when one message should route replies
somewhere else—for example, to support, sales, or the contact's account owner.
The field accepts template variables. Leave it blank to use the connection
default; when the connection has no reply-to email, replies go to the sender.
## Verify
1. Build a sequence with one email step and run yourself through it.
2. The email arrives from the address you configured, and it lands in the
inbox rather than spam.
3. Open the sequence in FounderHQ. The message shows as Sent, and moves to
Delivered once your provider reports back. SMTP connections stop at
Sent.
4. Open the email and click a link. The message picks up Opened and
Clicked.
## Troubleshoot
### The send fails immediately with a missing from address
The connection has no from address saved. Every email provider needs one,
and it has to be on a domain verified at that provider.
### Mail sends but never reaches Delivered
The provider's feedback path is not wired. Re-check the webhook or SNS
subscription above. On SMTP there is nothing to wire — Delivered is not
available.
### Resend or Postmark webhooks arrive but nothing changes
FounderHQ rejected them as unverified. For Resend, paste the signing
secret onto the connection. For Postmark, add the `X-Webhook-Secret`
header with a matching value.
# Sequences (https://www.getfounderhq.com/docs/sequences)
You build sequences inside FounderHQ: the steps, the waits, the branches,
and the message templates. FounderHQ still needs a way to put each message
on the wire. That is what a connection does.
## Bring your own provider
FounderHQ does not resell sending. You connect your own provider account,
so messages leave from your domain, your number, or your WhatsApp sender.
You keep your provider's pricing, your sending reputation, and your
compliance paperwork.
Connecting a provider gives you three things:
- **Delivery.** Sequence steps on that channel start sending.
- **Status back.** Delivery receipts, opens, and replies flow into
FounderHQ, so a later step can branch on what happened. How much you get
depends on the provider.
- **A choice per step.** When a brand has more than one connection for a
channel, a sequence step can name which one to send through.
To add one, open Sequences in FounderHQ and go to its connections area.
## Channels and providers
| Channel | Providers |
| --- | --- |
| Email | Resend, Postmark, Amazon SES, any SMTP server |
| SMS | Twilio, and ready-made recipes for Telnyx, Plivo, Sinch, Exotel, and MSG91 |
| WhatsApp | Meta Cloud API, Twilio, and ready-made recipes for 360dialog, Gupshup, icpaas, and Interakt |
| Push | Firebase Cloud Messaging |
| Anything else | A custom HTTP integration you define yourself |
## Two kinds of connection
**Built-in providers** — Resend, Postmark, SES, SMTP, Twilio, Meta
WhatsApp, and FCM. FounderHQ speaks each API directly. You paste
credentials and a couple of settings.
**HTTP integrations** — everything else. FounderHQ builds the HTTP request
for each message from a template you control. A *recipe* fills that
template in for a provider FounderHQ already knows: you type a few fields
and the request, the response mapping, and the delivery callback are set up
for you. A recipe connection runs on exactly the same machinery as one you
build by hand, so you can always inspect what it sends.
## What FounderHQ can report per channel
| Channel | Sent | Delivered | Opened / read | Clicked |
| --- | --- | --- | --- | --- |
| Email | Always | From the provider's feedback. Plain SMTP has none. | Yes — FounderHQ tracks opens itself | Yes — FounderHQ wraps links itself |
| SMS | Always | Where the provider reports delivery receipts | No | No |
| WhatsApp | Always | Where a status webhook is wired | Yes, with that same webhook | No |
| Push | Always | No | No | No |
A step that waits on "delivered" only works when the connection can report
it. FounderHQ checks this when you publish a sequence and tells you which
states this connection can actually reach.
## Pick your channel
- [Email (Postmark, Resend, SES, SMTP)](/sequences/email)
- [SMS (Twilio, Telnyx, Plivo, Sinch, Exotel, MSG91)](/sequences/sms)
- [India DLT](/sequences/india-dlt)
- [WhatsApp (Meta, Twilio, 360dialog, icpaas, interakt)](/sequences/whatsapp)
- [Push (FCM)](/sequences/push)
- [Custom HTTP integration](/sequences/custom-http-integration)
# India DLT (https://www.getfounderhq.com/docs/sequences/india-dlt)
Send SMS to Indian numbers without operator rejections. About 15 minutes in
FounderHQ, after your DLT registration is approved.
You need an approved DLT registration on an operator portal (Jio, Airtel,
Vodafone Idea, or BSNL), and an Indian SMS connection —
[Exotel or MSG91](/sequences/sms).
## What DLT asks of you
TRAI requires every business sending business-to-consumer SMS in India to
register on a Distributed Ledger Technology portal run by an operator. You
register three things:
- **Your business**, which gets a Principal Entity ID (PE ID).
- **Your sender headers** — the short name the message comes from, up to 11
characters. Indian headers are usually 6.
- **Your content templates** — the exact wording of each message, with the
changing parts written as `{#var#}`. Each approved template gets a DLT
template ID.
Your aggregator then wants some of these values attached to every message,
and the operator checks the message text against the registered template
before it delivers.
## Where each value goes in FounderHQ
| Value | Where you put it | Why there |
| --- | --- | --- |
| PE ID | On the SMS connection | It is the same for every message your business sends |
| Sender ID | On the SMS template | Different messages can use different headers |
| DLT template ID | On the SMS template | It identifies that message's registered wording |
| Message category | On the SMS template | It sets the regulatory route the aggregator uses |
Open Sequences in FounderHQ, open the SMS template, and fill in its DLT
settings. Your registered wording goes in the message body as usual —
FounderHQ's variables stand where the `{#var#}` placeholders stand in the
registered template.
## Message categories
Pick the one your DLT template was registered under:
- **Transactional** — OTPs and alerts tied to a transaction.
- **Promotional** — marketing, sent only to numbers not on DND.
- **Service implicit** — service updates a customer relationship implies.
- **Service explicit** — service messages the customer opted in to.
The category drives the route your aggregator uses. Promotional and
transactional traffic are billed and throttled differently, so a mismatch
gets messages rejected.
## Wording drift
Operators match the delivered text against the registered template. If the
two drift apart, delivery fails.
FounderHQ takes a snapshot of the message text at the moment you set or
confirm the DLT template ID. When you later edit the text, FounderHQ
compares it to that snapshot and warns you that the template no longer
matches what you registered. Re-register the wording on the DLT portal, or
put the text back.
## Two things worth knowing
**Your DLT template IDs are portable.** They are registered against your
business, not against your aggregator. Move from Exotel to MSG91 and the
same templates keep working. This is why FounderHQ keeps DLT values on the
template rather than on the connection.
**FounderHQ cannot check your IDs.** The DLT portals have no API to check
against, so FounderHQ takes what you type. A wrong ID shows up as a
rejection from your aggregator, not as an error in FounderHQ.
What FounderHQ does protect you from is a *blank* DLT value. If your
connection needs a sender ID or a DLT template ID and the template has
none, the send fails and says so. It never sends an empty regulatory field
and lets the operator guess.
## Verify
1. Open the SMS template in FounderHQ. Its DLT settings show a sender ID, a
DLT template ID, and a category, with no missing-field warning.
2. Run your own Indian number through a one-step sequence.
3. The message arrives with your registered header, and the sequence shows
it as Sent, then Delivered.
## Troubleshoot
### The send fails saying a DLT field is missing
The template has no sender ID or no DLT template ID. Fill both in and
publish the template again.
### The aggregator rejects the message
Either the DLT template ID does not match the wording sent, or the category
is wrong for the route. Compare the message text to the registered template
character by character — including punctuation and line breaks.
### You want to send to India through Twilio instead
Twilio delivers Indian SMS once your DLT registration is in place, and it
does not need per-message DLT fields — the carriers match on content.
Local aggregators are usually a lot cheaper for volume, which is why
FounderHQ ships Exotel and MSG91 recipes.
# Push (https://www.getfounderhq.com/docs/sequences/push)
Connect Firebase Cloud Messaging so sequence push steps reach your app.
About 5 minutes.
You need a Firebase project with your app registered, and a service account
in that project. Your contacts also need a device push token stored on them
in FounderHQ — that token is what a push step sends to. Then open Sequences
in FounderHQ and add a push connection.
## Connect Firebase
**Paste:** the whole service-account JSON file. In the Firebase console,
open Project settings → Service accounts and generate a new private key.
Paste the file's full contents, not just one field out of it.
FounderHQ stores it encrypted and uses it to mint short-lived access
tokens. It never appears in any request you can inspect.
That is the entire setup. FCM covers both iOS and Android for a Firebase
app, so one connection serves both.
## What a push message carries
A push template renders a title and a body. Both are required — a push with
either one empty fails before it leaves FounderHQ.
FounderHQ also attaches its own message ID to the notification's data
payload, under `fhqOutboundMessageId`. Your app can read it and report
delivered or opened back through the FounderHQ events SDK. If your template
sets its own data values, FounderHQ's key is added after them, so your data
can never overwrite it.
## What you can and cannot see
FCM tells you it accepted the message. It does not tell you the phone
showed it. Push messages in FounderHQ stay at Sent, and a sequence step
cannot wait on "delivered" over push.
If you want that signal, report it from your app: read
`fhqOutboundMessageId` when the notification arrives or is tapped, and send
an event with it.
A dead device token — the app was uninstalled, or the token expired — fails
permanently and is not retried.
## Verify
1. Build a sequence with one push step and run a contact with a live device
token through it.
2. The notification appears on the device with the title and body from your
template.
3. Open the sequence in FounderHQ. The message shows as Sent.
## Troubleshoot
### The send fails saying the service account is invalid
The pasted value is not the service-account JSON. Paste the whole file. It
must contain `project_id`, `client_email`, and `private_key`.
### The send fails saying the payload is missing title or body
The push template renders one of them empty. Check the variables it uses
against the contact you are sending to.
### One contact always fails while others work
That device token is dead. The app was removed, or the token rotated. Get a
fresh token from the device and store it on the contact.
# SMS (https://www.getfounderhq.com/docs/sequences/sms)
Connect your own SMS account so sequence SMS steps send. About 5 minutes
once you have the provider credentials.
You need an account with one of the providers below, and a sending number
or an approved sender ID. Sending to Indian numbers also needs DLT
registration — read [India DLT](/sequences/india-dlt) first.
Then open Sequences in FounderHQ and add an SMS connection.
## Twilio
Twilio is built into FounderHQ. It is the pick if you send worldwide and
want replies handled for you.
**Paste:** your Account SID and Auth Token, from the Twilio console
dashboard.
**Set:** either a sending number in E.164 form, or a Messaging Service SID
if you route through a service. FounderHQ can read the numbers and
messaging services on your account and set the webhook on the one you
choose, so you do not have to paste URLs into Twilio yourself.
Delivery receipts are automatic — FounderHQ asks Twilio to report the
result of every message. Inbound replies land on the same webhook, so STOP
and HELP are handled.
## Recipes for other providers
The five providers below are ready-made recipes. You type a few fields,
FounderHQ builds the request, and it sends a real test message before the
connection goes live.
### Telnyx
| Field | Where to find it |
| --- | --- |
| From number | An SMS-enabled Telnyx number in E.164 form, for example `+1 212 555 0123`. An approved alphanumeric sender ID also works. |
| API key | Telnyx Portal → API Keys. Use a V2 key. |
Delivery receipts are automatic. Every message carries a callback address,
so there is nothing to set up at Telnyx.
### Plivo
| Field | Where to find it |
| --- | --- |
| Auth ID | Plivo Console overview. Starts with `MA` or `SA`. |
| From number | An SMS-enabled Plivo number in E.164 form, or an approved alphanumeric sender ID. |
| Auth token | Plivo Console overview, next to the Auth ID. |
Delivery receipts are automatic.
### Sinch
| Field | Where to find it |
| --- | --- |
| Regional API host | The region shown in your Sinch dashboard: us, eu, au, br, or ca. Defaults to the US host. |
| Service plan ID | Sinch Dashboard → SMS → APIs → your service plan. |
| From number | An SMS-enabled Sinch number in E.164 form, or an approved alphanumeric sender ID. |
| API token | Sinch Dashboard → SMS → APIs → REST configuration. |
Delivery receipts are automatic. FounderHQ asks Sinch for a final report
per recipient.
### Exotel
For India. Read [India DLT](/sequences/india-dlt) as well —
Exotel checks your DLT registration on every message.
| Field | Where to find it |
| --- | --- |
| API host | `https://api.exotel.com`, or `https://api.in.exotel.com` if your account is on the Mumbai cluster. |
| Account SID | Exotel Dashboard → the account name in your API settings. |
| API key | Exotel Dashboard → Developer Settings → API key. |
| DLT entity ID (PE ID) | Your Principal Entity ID from the DLT portal. The same value for every message you send. |
| API token | Exotel Dashboard → Developer Settings → API token. |
Delivery receipts are automatic. The test send asks you for a sender ID and
a DLT template ID, because real sends take those from the SMS template.
### MSG91
For India. Read [India DLT](/sequences/india-dlt) as well.
| Field | Where to find it |
| --- | --- |
| Auth key | MSG91 dashboard → Configurations → Auth Key. |
**One thing to set up at MSG91:** open Webhook (New) and add the callback
URL FounderHQ shows on the connection, for SMS delivery reports. Delivery
ticks then arrive automatically. The older v2 webhook format still works,
but Webhook (New) is the one to use.
Like Exotel, the test send asks you for a sender ID and a DLT template ID.
## What delivery receipts tell you
FounderHQ records only final answers. Delivered becomes Delivered. A
definitive failure becomes Bounced. In-between states such as queued, sent,
or dispatched are accepted and dropped — the send itself already recorded
Sent.
A failed SMS never removes a contact from your audience on its own.
Carrier failures routinely mean "phone switched off" or "out of coverage",
which is not a reason to stop messaging someone. A STOP reply is, and
FounderHQ acts on that where the provider forwards replies.
## Verify
1. Build a sequence with one SMS step and run your own number through it.
2. The message arrives from the number or sender ID you configured.
3. Open the sequence in FounderHQ. The message shows Sent, then moves to
Delivered once the carrier reports back — usually within seconds.
## Troubleshoot
### The connection will not activate
The test send failed. The error from the provider is shown as-is: a bad key
reads as an auth failure, a sender the provider does not own reads as an
invalid from address.
### Messages send but never reach Delivered
On MSG91, the callback URL is not registered under Webhook (New). On the
other recipes delivery is automatic, so a message stuck at Sent means the
carrier has not answered yet.
### Sends to Indian numbers fail
DLT fields are missing or wrong on the template. See
[India DLT](/sequences/india-dlt).
# WhatsApp (https://www.getfounderhq.com/docs/sequences/whatsapp)
Connect your own WhatsApp Business sender so sequence WhatsApp steps send.
About 10 minutes once your number is live at the provider.
You need a WhatsApp Business number already approved and live with Meta or
with a Business Solution Provider, and at least one approved message
template. Then open Sequences in FounderHQ and add a WhatsApp connection.
## The 24-hour rule
WhatsApp does not let you write to people freely. Two kinds of message
exist:
- **Template messages.** Wording you registered with Meta and Meta
approved. You can send these any time.
- **Session messages.** Free text. Allowed only within 24 hours of that
person's last message to you.
FounderHQ follows the person's last inbound message to guess whether the
window is open, but the provider decides. A session message sent after the
window closes comes back rejected — Meta answers with error 131047, Twilio
with 63016 — and FounderHQ marks the message failed and shows you why. It
never quietly drops it or silently swaps in something else.
Practical consequence: a sequence that reaches out first must use a
template. Free text only works as a reply.
## Meta Cloud API
The direct route. No middleman, Meta's own pricing.
**Paste:** a long-lived access token from your Meta app.
**Set:** the phone number ID of your WhatsApp sender.
**For replies and delivery ticks:** in your Meta app's webhook settings,
point the webhook at the URL FounderHQ shows on the connection. Meta first
sends a verification challenge — set a verify token on the connection and
enter the same value at Meta. Also set your Meta app secret on the
connection, so FounderHQ can check the signature on every incoming webhook.
FounderHQ can pull your approved templates from Meta so you pick one by
name instead of typing it.
## Twilio WhatsApp
The pick if you already run Twilio.
**Paste:** your Account SID and Auth Token.
**Set:** your WhatsApp sender number. FounderHQ sends from that number
directly — Twilio Messaging Services are not used for WhatsApp.
FounderHQ can read the WhatsApp senders on your Twilio account and set the
webhook on the one you choose, so replies and delivery ticks arrive without
you pasting URLs into Twilio.
Templates work by their approved WhatsApp name and language. FounderHQ
looks up the matching Twilio Content template inside your own Twilio
account and remembers it for that connection. Move the same FounderHQ
template to a different Twilio account and it resolves again there.
## Business Solution Providers
Four BSPs ship as recipes. You type a few fields and FounderHQ builds the
rest.
### 360dialog
| Field | Where to find it |
| --- | --- |
| Phone number ID | 360dialog Hub → WhatsApp accounts |
| D360 API key | 360dialog Hub → API keys |
**Webhook:** paste the URL FounderHQ shows into the 360dialog Hub, under
WhatsApp Accounts → your number → webhook. Delivery ticks, read receipts,
and replies then arrive on their own.
360dialog sends events to one webhook per number. If another tool was
receiving them, it stops.
Session messages work. FounderHQ can pull your approved templates.
### Gupshup
| Field | Where to find it |
| --- | --- |
| App ID | Your Gupshup partner portal |
| Phone number ID | The WhatsApp number behind that Gupshup app |
| Partner app token | Your Gupshup partner portal |
**Webhook:** in the partner portal, add a callback subscription for your
app with type V3 and paste the URL FounderHQ shows. Gupshup allows several
callbacks, so you can keep the ones you already have.
Session messages work.
### icpaas
| Field | Where to find it |
| --- | --- |
| API base URL | The host icpaas gave you. Defaults to `https://icpaas.in`. |
| Phone number ID | Your icpaas dashboard |
| WhatsApp Business Account ID | Optional. Only needed to pull your approved templates into FounderHQ — sends work without it. |
| API key | Your icpaas dashboard |
**Webhook:** paste the URL FounderHQ shows as the delivery webhook in your
icpaas dashboard.
Session messages work.
### Interakt
| Field | Where to find it |
| --- | --- |
| API key | Interakt → Settings → Developer Settings |
Interakt's public API sends approved templates only. A session message on
this connection fails with a clear message rather than going out wrong.
There is no delivery feedback either: messages stay at Sent. Replies and
STOP handling stay inside Interakt for now.
## Templates
FounderHQ never registers or approves templates. You create them at Meta or
in your BSP's console and wait for approval there. FounderHQ sends an
approved template by its name and language, and fills its variables in
order.
For Meta, 360dialog, and icpaas, FounderHQ can pull the approved list so
you pick from it. For the others, enter the template's name and language as
approved.
## Verify
1. Build a sequence with one WhatsApp template step and run your own
number through it.
2. The message arrives from your business sender.
3. Open the sequence in FounderHQ. The message shows Sent, then Delivered,
then Read once the person opens it. An Interakt connection stops at
Sent.
4. Reply from your phone. The reply shows up in FounderHQ, on every
connection except Interakt.
## Troubleshoot
### A free-text message failed with a window error
The person's 24-hour window is closed. Use an approved template for that
step.
### The connection sends but nothing ever reaches Delivered
The webhook is not wired. Meta, 360dialog, Gupshup, and icpaas all need the
URL pasted at the provider. Interakt has no delivery feedback at all.
### A template send is rejected as unknown
The name or language does not match an approved template on that account.
Pull the list again, or copy the name exactly as the provider shows it.
# Agent skills (https://www.getfounderhq.com/docs/analytics/ai-resources/agent-skills)
FounderHQ plans to publish agent skills: packaged instructions that teach
a coding agent how to install an SDK, name events, and wire up
attribution without being told each time. None are published yet, so
there is nothing to install from this page today.
Until they ship, give your agent the docs directly. Connect the
[MCP server](/analytics/ai-resources/mcp-server) so it can search and read
these pages while it works, or paste
[`/docs/llms.txt`](/analytics/ai-resources/llms-and-markdown-routes) into its
context so it knows what exists. Both work in every agent tool today,
and both will keep working after skills arrive. This page will list the
skills and how to install them when they are ready.
# AI resources (https://www.getfounderhq.com/docs/analytics/ai-resources)
You probably build with Claude Code, Cursor, Lovable, or Bolt. These docs
are built for that. Every page is available as clean Markdown, the whole
site fits in one file, and there is an MCP server your agent can search
directly.
## Pick one
| You want | Use |
| --- | --- |
| Your agent to search these docs live, inside your editor | [MCP server](/analytics/ai-resources/mcp-server) |
| To paste one page into a chat | Add `.md` to the page URL. See [llms.txt and markdown routes](/analytics/ai-resources/llms-and-markdown-routes). |
| To give an agent a map of the whole site | [`/docs/llms.txt`](/llms.txt) |
| To give an agent every page at once | [`/docs/llms-full.txt`](/llms-full.txt) |
## The fastest setup
Connect the MCP server to Claude Code:
```bash
claude mcp add --transport http founderhq-docs https://www.getfounderhq.com/docs/mcp
```
Your agent can now search and read these docs while it writes your
integration. Full instructions, and configuration for other clients, are
on the [MCP server](/analytics/ai-resources/mcp-server) page.
## Why this matters for your integration
An agent that guesses at an SDK writes code that almost works. An agent
that reads the real page gets your key names, your event names, and your
option names right the first time. The pages below cost you one setup
step and save you a debugging session.
# llms.txt and markdown routes (https://www.getfounderhq.com/docs/analytics/ai-resources/llms-and-markdown-routes)
Every page on this site has a Markdown twin. Nothing to install, nothing
to configure.
## Read one page as Markdown
Add `.md` to any docs URL.
```
https://www.getfounderhq.com/docs/analytics/protocol-reference/envelope.md
```
You get the page as plain Markdown, with the title and canonical URL on
the first line. Paste it into any chat.
Every page also has a copy button and a "view as Markdown" menu at the
top, and an agent link at the bottom. You never need to type the `.md`
by hand.
## Ask for Markdown with a header
If your client cannot change the URL, ask for Markdown in the
`Accept` header instead.
```bash
curl -H "Accept: text/markdown" \
https://www.getfounderhq.com/docs/analytics/protocol-reference/envelope
```
The same URL returns HTML in a browser and Markdown to your agent. The
response carries `Vary: Accept`, so caches keep the two apart.
`text/plain` and `text/x-markdown` work too. FounderHQ serves Markdown
when you rank it at or above `text/html`.
## Give an agent the whole site
Two files cover the site.
| File | What it holds | Use it when |
| --- | --- | --- |
| [`/docs/llms.txt`](/llms.txt) | A map: every page title, description, and absolute URL | The agent should pick what to read. Small enough to paste into a system prompt. |
| [`/docs/llms-full.txt`](/llms-full.txt) | Every page, in full | The agent has a large context window and you want one paste. |
```bash
curl https://www.getfounderhq.com/docs/llms.txt
```
`/docs/llms-full.txt` leaves out the generated API reference pages, because
the endpoint schemas would crowd out the guides. Read those pages
individually with `.md`, or let the
[MCP server](/analytics/ai-resources/mcp-server) fetch them on demand.
## Which one to reach for
- Working in an editor with an agent: use the
[MCP server](/analytics/ai-resources/mcp-server). It searches, so the agent
reads only what it needs.
- Pasting into a chat window: use `.md` on the page you care about.
- Scripting or building your own tool: use `/docs/llms.txt` to discover pages,
then fetch each one with `.md`.
# MCP server (https://www.getfounderhq.com/docs/analytics/ai-resources/mcp-server)
FounderHQ runs an MCP server for this documentation. Connect it once, and
your coding agent searches and reads these pages while it writes your
integration. Setup takes about a minute.
An MCP client that supports HTTP transport. Claude Code, Cursor, and
most current agent tools do.
The endpoint is:
```
https://www.getfounderhq.com/docs/mcp
```
It is public and read-only. It reads documentation and nothing else. It
never sees your keys, your account, or your data.
## Connect Claude Code
```bash
claude mcp add --transport http founderhq-docs https://www.getfounderhq.com/docs/mcp
```
## Connect another client
Most clients read a JSON config. Add this entry:
```json
{
"mcpServers": {
"founderhq-docs": {
"type": "http",
"url": "https://www.getfounderhq.com/docs/mcp"
}
}
}
```
## The two tools
| Tool | Input | Returns |
| --- | --- | --- |
| `search_docs` | `query` — words or a question | The top 10 matches, each with a title, an absolute URL, and a snippet, as JSON |
| `read_doc` | `path` — a path inside `/docs`, for example `/docs/getting-started` | That page as Markdown |
The normal pattern is search, then read. Your agent calls `search_docs`
to find the right page, then `read_doc` on the URL it found.
`read_doc` accepts a path with or without a leading slash, and tolerates
a trailing `.md`. It refuses anything outside `/docs`.
## Verify it works
Ask your agent a question only these docs can answer. For example:
> Using the founderhq-docs MCP server, what are the four per-event result
> statuses in the Events v2 protocol?
The answer is `ok`, `warning`, `drop`, and `retry`. If your agent names
all four, the connection works. If it guesses or hedges, the server is
not connected.
## Troubleshoot
### The agent says the server has no tools
Confirm you used HTTP transport, not stdio. This server has no command to
run locally; it is a URL.
### The agent answers from memory instead of the docs
Name the server in your prompt: "use the founderhq-docs MCP server".
Agents skip tools they were not pointed at.
### You want the docs without a live connection
Use [`/docs/llms.txt` and the Markdown routes](/analytics/ai-resources/llms-and-markdown-routes)
instead. They need no client support at all.
# Accounts and groups (https://www.getfounderhq.com/docs/analytics/concepts/accounts-and-groups)
An account is the company or workspace a contact belongs to. Other
tools call this a group. FounderHQ calls it an account, because it is
also who pays.
Consumer product? You can skip this page. Contacts are enough.
## Why accounts exist
If your product is sold to teams, one payment covers many seats. Tie
that payment to a single contact and you credit the payer, ignore the
teammate whose demo actually won the deal, and read `$0` next to every
other seat. An account fixes that: revenue and touches belong to the
workspace.
## Tell FounderHQ which account is active
```ts
founderhq.setAccount("workspace_123");
founderhq.setAccountProperties({ name: "Acme", seats: 12 });
founderhq.clearAccount();
```
Every event captured afterwards carries that account. On the web, the
active account is per tab, so someone with two workspaces open in two
tabs reports each correctly. On mobile, it is per app session.
When a sign-in changes both the person and the workspace, change them
together:
```ts
founderhq.identify("user_8421", { email: "jane@acme.com" }, {
account: "workspace_123",
});
```
Switching accounts does not restart the person's session. It rotates a
separate account span, so your session metrics stay honest.
## Declare membership from your server
The active account on an event is an observation. Your backend knows
the truth: who joined, who left, and when. Only the Node SDK can say
so.
```ts
events.accountMembership({
account: "workspace_123",
userId: "user_8421",
state: "left",
effectiveAt: new Date("2026-08-17T10:30:00Z"),
idempotencyKey: "membership_user_8421_left_2026_08_17",
});
```
- `joined` and `left` record real changes. A leave stops later activity
from counting for that account.
- `retracted` means the membership was wrong from the start. Do not use
it when someone departs.
- `upsertAccount` updates the account's own properties from the server.
## How accounts change the numbers
- A payment belongs to exactly one subject: a contact or an account.
Never both, and never copied to each seat.
- Account attribution uses the touches of everyone who was a member at
the time the money was earned. Touches from before the account
existed still count, because visit → signup → create workspace → pay
is the normal path.
- If a person belongs to two accounts and browses without an active
account, that touch counts for neither.
- Bot traffic never creates membership and never counts as a touch.
## Related
- [Attribution](/analytics/concepts/attribution)
- [Identity and contacts](/analytics/concepts/identity-and-contacts)
- [Node SDK](/analytics/sdks/node)
# Attribution (https://www.getfounderhq.com/docs/analytics/concepts/attribution)
Every payment gets two answers, not one:
- **Introduced by** — the earliest qualifying touch inside the window.
This is who found the customer.
- **Closed by** — the latest qualifying touch before the money. This is
who finished the job.
A blog post that brought someone in six weeks ago and an ad they
clicked yesterday both did work. One number would hide one of them.
## What counts as a touch
A touch is a human `$session_start`, `$pageview`, or `$screen` that
carries a real source: a non-direct channel, or any ad click ID.
- A visit with no campaign and no referrer is not a qualifying touch.
- Bot visits never count. See
[Bot traffic policy](/analytics/concepts/bot-traffic-policy).
- If no qualifying touch exists in the window, the payment is
**Direct**. FounderHQ never promotes a random visit to fill the gap,
and Direct never erases a known earlier source.
## The window
The window is counted back from the day the money was earned, not the
day the webhook arrived. That keeps a renewal on the renewal's own
merits. The default is 90 days. You can also use 30, 60, or 180. Each
result records the model and the window that produced it, so two
reports never disagree silently.
## Where the source comes from
The SDK reads campaign parameters and ad click IDs from the URL on
every landing, then keeps both the first and the latest value on the
contact. There are 24 keys: the five `utm_*` parameters plus 19 click
IDs from Google, Meta, Microsoft, LinkedIn, TikTok, Reddit, and others.
The full list is in the
[event taxonomy](/analytics/protocol-reference/event-taxonomy). Mobile deep
links and the Play install referrer feed the same keys.
## Keep the chain through checkout
The gap most products lose is checkout. Attach the visitor's IDs to the
payment so the payment can find the person.
```ts
const metadata = founderhq.checkoutMetadata();
// pass metadata into your Stripe or Dodo checkout session
```
Renewals do not carry that metadata, so FounderHQ also remembers the
provider customer once matched. See
[Checkout metadata and payment links](/analytics/revenue/checkout-metadata-and-payment-links).
If a payment cannot be matched to anyone, it is parked rather than
guessed, with candidate matches for you to confirm.
## When facts arrive late
A touch that shows up after a decision was made does not quietly
rewrite history. The decision is recomputed on request, and the new
answer is added as a new version, so a number you screenshotted last
week still explains itself.
## Related
- [Revenue](/analytics/revenue)
- [Sessions](/analytics/concepts/sessions)
- [Accounts and groups](/analytics/concepts/accounts-and-groups)
# Bot traffic policy (https://www.getfounderhq.com/docs/analytics/concepts/bot-traffic-policy)
Crawlers hit your site all day: search engines, AI crawlers, social
preview fetchers, SEO tools, uptime monitors, and your own scripts.
FounderHQ separates them from people before any number is drawn. You do
not configure this.
## How a visit is classified
Classification happens on the server, when the event arrives — not in
the browser, where anyone could switch it off. It uses user-agent rules
covering AI crawlers, search engines, social preview fetchers, SEO
tools, monitoring services, plain HTTP clients, and browser automation.
The web SDK adds one signal of its own: whether the page is being
driven by automation.
## What happens to a bot visit
Bot events are recorded, not thrown away. Each one is stored with a bot
classification and a synthetic guest identity that is shared by all
bots on that key. Two things follow:
- **No bot ever becomes a contact.** A crawler cannot create people in
your audience, cannot enter a segment, and cannot enter a sequence.
- **Bot events do not count toward your plan's event limit.**
Bot traffic is also excluded from sessions, contact activity, and every
human metric in the product. Visitors, conversions, and revenue read
the same whether a crawler visited once or a million times.
## Why keep them at all
Because "which AI crawlers read my launch post" is a real question, and
2026 is a good year to be able to answer it. The events stay queryable
by classification, so AI and search crawler visibility is available
without ever touching your product metrics.
## The one limit
The bot signals travel on your publishable key, which anyone can read
from your page. So bot storage has its own capped pool, sized to your
plan's event limit and counted separately from your real events. Once
that pool is full, further bot events are refused at ingest and the
response says so explicitly. Your human events are never affected by a
flood of bot traffic.
## What this means for you
- Your first-week numbers are people, not preview fetchers.
- A viral link that draws scrapers does not inflate your bill.
- If a real browser is ever misread as a bot, tell support with the
user agent. Classification is server-side, so a fix reaches everyone
without an SDK release.
## Related
- [Sessions](/analytics/concepts/sessions)
- [Attribution](/analytics/concepts/attribution)
- [What FounderHQ analytics is](/analytics/getting-started/what-founderhq-analytics-is)
# Consent and privacy (https://www.getfounderhq.com/docs/analytics/concepts/consent-and-privacy)
You decide what FounderHQ may collect, per visitor. The web SDK starts in
`pending` when there is no remembered choice and no explicit
`consent_default`.
| State | What it means | What it sends |
| --- | --- | --- |
| pending | The visitor has not answered yet. | Determined by `cookieless_mode`; cookieless page measurement by default. |
| granted | Storage and durable identity are allowed. | Everything you configured. |
| denied | No storage and no durable identity are allowed. | Determined by `cookieless_mode`; cookieless page measurement by default. |
| off | Collection is switched off completely. | Nothing. |
## Switching states
```ts
founderhq.consent("granted"); // after the visitor accepts
founderhq.consent("denied"); // after the visitor declines
founderhq.optOut(); // total off switch
founderhq.optIn(); // back to granted
founderhq.isOptedOut(); // true only in the off state
```
The choice is remembered in a first-party cookie, so it survives the
next visit. `identify` never changes consent; call `consent("granted")`
after your consent UI reports acceptance.
Set the starting point at init:
- `consent_default: "denied"` — wait for consent before anything is
stored. Use this if you show a banner.
- `opt_out_by_default: true` — start in the off state.
- `respect_dnt: true` — start in the off state when the browser sends
Do Not Track.
## Cookieless modes
Choose the behavior locally with `cookieless_mode`. Remote config never
changes this choice.
| Mode | Pending | Granted | Denied |
| --- | --- | --- | --- |
| `"off"` | nothing | normal | nothing |
| `"always"` | cookieless | cookieless | cookieless |
| `"when_not_granted"` (default) | cookieless | normal | cookieless |
| `"on_reject"` | nothing | normal | cookieless |
`optOut()` is a hard off switch regardless of mode. Cookieless measurement is
web only; mobile and server SDKs do not implement this mode.
A cookieless visitor sends two events and nothing else:
`$pageview` and `$pageleave`. Those events carry only the shared
`$founderhq_cookieless` sentinel, with no visitor-specific ID, session, or
person properties. The property list is fixed:
- `$pathname`, `$referring_domain`, `$lib`, `$lib_version`,
`$platform`, and the five `utm_*` keys
- on `$pageleave` only: `$page_duration_ms`,
`$max_scroll_percentage`, `$leave_reason`
Cookieless traffic gives you honest page counts. It does not give you
contacts, returning visitors, funnels, revenue attribution, or custom
events. Those need a granted visitor.
## What no SDK ever collects
No FounderHQ SDK collects advertising IDs (IDFA or GAID), the device
contact list, arbitrary page text, input values, DOM snapshots, console
logs, network bodies, or native view trees.
You control the rest:
- `redactUrlParams` strips query parameters before they leave the page.
- `beforeSend` gives you the final say on every event; return `null` to
drop it.
- `reset()` clears the identity on the device, for sign-out.
The consent choice lives on the device. A returning visitor keeps the
answer they gave until they clear their browser storage.
## Related
- [Web SDK](/analytics/sdks/web)
- [Identity and contacts](/analytics/concepts/identity-and-contacts)
- [Bot traffic policy](/analytics/concepts/bot-traffic-policy)
# Identity and contacts (https://www.getfounderhq.com/docs/analytics/concepts/identity-and-contacts)
A contact is one person in your audience. Every event belongs to a
contact, even before you know who they are.
## Anonymous first
On the first visit, the SDK creates an anonymous ID and stores it. On
the web it also writes a first-party cookie on your registrable domain,
so `www.example.com` and `app.example.com` stay one visitor with one
attribution chain. Until you identify them, the contact is a guest.
## Identify with your own ID
Call `identify` when someone signs in or signs up. Pass the stable user
ID from your database.
```ts
founderhq.identify("user_8421", { email: "jane@acme.com" });
```
FounderHQ then merges the guest's history into that contact. The
pageviews and events from before the sign-up stay attached, which is
what makes attribution work.
Rules worth knowing:
- One external ID per contact. Use the same ID in your browser SDK,
your mobile SDK, and the [Node SDK](/analytics/getting-started/quickstart-server-node).
- Never pass an email, a session ID, or a random value as the ID.
- Placeholder values are rejected: `anonymous`, `guest`, `id`, `null`,
`undefined`, `none`, `nil`, `nan`, `[object Object]`, and empty
strings.
- If that browser was already identified as a different person,
FounderHQ does not merge the two. It starts a fresh anonymous
identity instead.
- Call `reset()` on sign-out, so the next person on a shared machine
starts clean.
- Calling `identify` again for the same person is safe. Do it on every
page load if you like. Only the first call records an identification;
later calls with new properties become a property update, and calls
with nothing new send nothing.
## Properties on the person
`identify` accepts properties. You can also set them later.
```ts
founderhq.setPersonProperties({ plan: "growth" }, { signup_source: "podcast" });
```
The first argument overwrites. The second is set once and never
overwritten, which is how you keep first-touch facts honest. FounderHQ
does this for captured facts too: it stores both the latest value and
the initial value of things like the campaign, the browser, and the
country.
By default (`person_profiles: "identified_only"`) property changes made
before `identify` wait on the device and travel with the identify call,
so you never create profiles for people you do not know. Set
`person_profiles: "always"` to profile guests too, or `"never"` to
stitch identity without writing person properties.
### Properties FounderHQ understands
Any property is stored and shown on the contact. These names also fill
the contact's own fields:
| Property | Contact field |
| --- | --- |
| `email` | Email, used to match contacts |
| `phone` | Phone, used to match contacts |
| `name` | Split on the first space into first and last name |
| `firstName` / `first_name`, `lastName` / `last_name` | First and last name; these win over `name` |
| `avatarUrl` | Photo on the contact |
| `timezone` | Time zone |
| `locale` | Locale |
```ts
founderhq.identify("user_8421", {
email: "jane@acme.com",
name: "Jane Cooper",
avatarUrl: "https://cdn.acme.com/avatars/jane.png",
});
```
## Properties on every event
Super properties ride along on every later event from that device.
```ts
founderhq.register({ workspace_tier: "growth" });
founderhq.registerOnce({ first_seen_variant: "b" });
founderhq.unregister("workspace_tier");
```
## From the server
Server events name the contact directly, by `externalId`, `email`, or
`phone`. Use the same external ID as the client and you keep one
contact.
## Related
- [Web SDK](/analytics/sdks/web)
- [Node SDK](/analytics/sdks/node)
- [Accounts and groups](/analytics/concepts/accounts-and-groups)
- [Consent and privacy](/analytics/concepts/consent-and-privacy)
# Concepts (https://www.getfounderhq.com/docs/analytics/concepts)
Six short pages. Read the one you need, not all of them.
- [Identity and contacts](/analytics/concepts/identity-and-contacts) — how an
anonymous visitor becomes one contact across devices.
- [Sessions](/analytics/concepts/sessions) — how FounderHQ groups events into
visits, and when a visit ends.
- [Consent and privacy](/analytics/concepts/consent-and-privacy) — granted,
denied, and off, and what each state sends.
- [Accounts and groups](/analytics/concepts/accounts-and-groups) — measuring
companies, not only seats.
- [Attribution](/analytics/concepts/attribution) — Introduced by and Closed
by, and how each payment gets a source.
- [Bot traffic policy](/analytics/concepts/bot-traffic-policy) — why crawler
visits never reach your metrics or your bill.
New here? Start with
[What FounderHQ analytics is](/analytics/getting-started/what-founderhq-analytics-is).
# Sessions (https://www.getfounderhq.com/docs/analytics/concepts/sessions)
A session is one visit. FounderHQ groups a contact's events into
sessions so you can read a timeline as "what happened in this sitting",
not as a flat list.
## When a session starts and ends
The SDK creates the session ID on the device. It is a UUIDv7, so
sessions sort by time. A new session starts when:
- the visitor has been inactive for 30 minutes, or
- the current session has run for 24 hours.
Each new session opens with a `$session_start` event. Everything
captured after it carries the same session ID until the next rotation.
## Web sessions cross your subdomains
The session ID lives in a first-party cookie (`fhq_ses`) on your
registrable domain. A person who moves from `www.example.com` to
`app.example.com` keeps one session and one attribution chain. Set
`cookie_domain` to override the domain, or `cross_subdomain: false` to
stop sharing.
Two more IDs make web sessions readable:
- `$window_id` — one per browser tab. A person with three tabs open has
one session and three window IDs.
- `$pageview_id` — one per page. `$pageleave` reports how long that page
was open, how long it was active, and how far the person scrolled.
## Mobile sessions
Mobile SDKs use the same rules and the same 30-minute idle rotation.
The session ID and the queued events are stored on the device, so a
restart does not lose them. Each screen gets a `$screen_id`.
## Reading and controlling the session
```ts
const sessionId = founderhq.getSessionId();
```
Use it when you need to correlate a support ticket or a server log with
a visit. It returns `null` when collection is not granted.
Turn automatic session capture off with `capture_sessions: false` at
init, or from the key's remote settings in the app. Events still carry
a session ID; you only stop the `$session_start` event.
## Where sessions show up
- A contact's activity groups into sessions, with the entry page, exit
page, pageview count, and whether the visit converted.
- [Attribution](/analytics/concepts/attribution) counts a `$session_start`,
`$pageview`, or `$screen` as a possible touch.
- Bot visits get sessions of their own and never join your session
metrics. See [Bot traffic policy](/analytics/concepts/bot-traffic-policy).
## Related
- [Web SDK](/analytics/sdks/web)
- [Session properties](/analytics/protocol-reference/event-taxonomy)
# Create an account context token (https://www.getfounderhq.com/docs/analytics/api-reference/createAccountContextToken)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Create a revenue attribution token (https://www.getfounderhq.com/docs/analytics/api-reference/createRevenueAttributionToken)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Create a generic revenue command (https://www.getfounderhq.com/docs/analytics/api-reference/createRevenueCommand)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Get remote SDK configuration (https://www.getfounderhq.com/docs/analytics/api-reference/getSdkConfig)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Ingest a client event batch (https://www.getfounderhq.com/docs/analytics/api-reference/ingestClientEvents)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Ingest server events (https://www.getfounderhq.com/docs/analytics/api-reference/ingestServerEvents)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Getting started (https://www.getfounderhq.com/docs/analytics/getting-started)
Pick your platform, install the SDK, and see your first event in
FounderHQ. Each quickstart takes about 5 minutes.
## Start here
- [What FounderHQ analytics is](/analytics/getting-started/what-founderhq-analytics-is)
— what you get, in one page.
## Quickstarts
| Platform | Use it when |
| --- | --- |
| [Web (snippet)](/analytics/getting-started/quickstart-web-snippet) | You can edit your site's HTML. No build step. |
| [Web (npm)](/analytics/getting-started/quickstart-web-npm) | You build with React, Next.js, Vue, or Svelte. |
| [React Native / Expo](/analytics/getting-started/quickstart-react-native-expo) | Your app is React Native or Expo. |
| [iOS](/analytics/getting-started/quickstart-ios) | Your app is Swift or SwiftUI. |
| [Android](/analytics/getting-started/quickstart-android) | Your app is Kotlin, with or without Compose. |
| [Server (Node)](/analytics/getting-started/quickstart-server-node) | You send facts your backend owns, like payments. |
Most products use two SDKs. One client SDK captures what people do. The
Node SDK sends what only your server knows.
## Then read
- [Identity and contacts](/analytics/concepts/identity-and-contacts) — how a
visitor becomes a contact.
- [Attribution](/analytics/concepts/attribution) — which channel introduced
and closed each customer.
- [Consent and privacy](/analytics/concepts/consent-and-privacy) — what you
send, and when you stop.
# Quickstart: Android (https://www.getfounderhq.com/docs/analytics/getting-started/quickstart-android)
Add the dependency, create the client once, and see your first event in
FounderHQ. About 5 minutes.
You need a FounderHQ account, Android Studio, and a publishable key. In
the app, open **Sequences → Settings → Event API keys**, create a key
for **Browser/mobile**, and copy it. The full key is shown only once.
## 1. Add the dependency
In your app module's `build.gradle.kts`:
```kotlin
dependencies {
implementation("com.getfounderhq:events:0.8.0")
}
```
Using Navigation Compose? Add the companion artifact too.
```kotlin
implementation("com.getfounderhq:events-compose:0.8.0")
```
## 2. Create the client in your Application class
```kotlin
import android.app.Application
import com.founderhq.events.FounderHQEvents
class MyApplication : Application() {
lateinit var events: FounderHQEvents
override fun onCreate() {
super.onCreate()
events = FounderHQEvents(this, "fhq_pk_XXXX")
}
}
```
Register the class in `AndroidManifest.xml` with
`android:name=".MyApplication"` if you have not already.
App and activity lifecycle, activity screens, sessions, and on-device
queuing start immediately. The queue survives a restart, so events
captured offline still arrive.
To change the defaults, pass a config:
`FounderHQEvents(this, "fhq_pk_XXXX", FounderHQEventsConfig(captureScreens = false))`.
## 3. Track Compose screens
Activity screens are captured for you. Compose destinations are not.
```kotlin
import com.founderhq.events.compose.FounderHQNavigationObserver
FounderHQNavigationObserver(navController = navController, events = events)
```
Place it inside the composable that owns your `NavHost`.
## 4. Identify and capture
```kotlin
events.identify("user_8421", mapOf("email" to "jane@acme.com"))
events.capture("trial.started", mapOf("plan" to "pro"))
```
Pass your own stable user ID, not an email. Call `events.reset()` on
sign-out.
Reading Play Install Referrer data? Hand it over, so the campaign that
drove the install is kept: `events.captureInstallReferrer(properties)`.
For universal links, call `events.captureDeepLink(url)`.
## Verify
1. Run the app on an emulator or device and trigger the event.
2. In FounderHQ, open **Sequences → Events**.
3. You see `trial.started` with `plan: pro`, a `$session_start`, and a
`$screen` for the screen you opened.
4. Open **Sequences → Contacts**. Jane is there with her email.
Events can take a few seconds to appear. The SDK batches them. To send
immediately, call `events.flush()` off the main thread.
## Troubleshoot
### No `$screen` events from Compose
Only activity screens are automatic. Add `FounderHQNavigationObserver`
next to your `NavHost`.
### Purchases are not attributed
In-app purchases need the purchase claim flow, not `capture`. See
[Mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Next
- [Android SDK reference](/analytics/sdks/android)
- [Identity and contacts](/analytics/concepts/identity-and-contacts)
# Quickstart: iOS (https://www.getfounderhq.com/docs/analytics/getting-started/quickstart-ios)
Add the package, create the client once, and see your first event in
FounderHQ. About 5 minutes.
You need a FounderHQ account, Xcode, an app targeting iOS 15 or later,
and a publishable key. In the app, open **Sequences → Settings → Event
API keys**, create a key for **Browser/mobile**, and copy it. The full
key is shown only once.
## 1. Add FounderHQEvents
In Xcode, open **File → Add Package Dependencies** and add the
`https://github.com/FounderHQ/founderhq-events-ios` repository, version
**0.8.0** or later. Add the **FounderHQEvents** library to your
app target.
CocoaPods instead? Add `pod 'FounderHQEvents', '~> 0.8.0'` to your `Podfile` and run
`pod install`.
## 2. Create the client once
Create one shared client and hold it for the life of the app.
```swift
import FounderHQEvents
let events = FounderHQEvents(apiKey: "fhq_pk_XXXX")
```
App lifecycle, UIKit screens, sessions, and on-device queuing start
immediately. The queue survives a restart, so events captured offline
still arrive.
To change the defaults, pass a configuration.
```swift
let events = FounderHQEvents(
apiKey: "fhq_pk_XXXX",
configuration: FounderHQEventsConfiguration(captureScreens: false)
)
```
## 3. Track SwiftUI screens
UIKit screens are captured for you. SwiftUI views are not, so mark the
ones you care about.
```swift
PricingView()
.founderHQScreen("Pricing", client: events)
```
## 4. Identify and capture
```swift
events.identify("user_8421", properties: ["email": "jane@acme.com"])
events.capture("trial.started", properties: ["plan": "pro"])
```
Pass your own stable user ID, not an email. Call `events.reset()` on
sign-out.
Handling a universal link? Pass it on, so the campaign that brought the
person survives the install.
```swift
events.captureDeepLink(url)
```
## Verify
1. Run the app on a simulator or device and trigger the event.
2. In FounderHQ, open **Sequences → Events**.
3. You see `trial.started` with `plan: pro`, a `$session_start`, and a
`$screen` for the screen you opened.
4. Open **Sequences → Contacts**. Jane is there with her email.
Events can take a few seconds to appear. The SDK batches them. To send
immediately, `await events.flush()`.
## Troubleshoot
### No `$screen` events from SwiftUI
Only UIKit screens are automatic. Add `.founderHQScreen(_:client:)` to
each SwiftUI view you want to see.
### Purchases are not attributed
In-app purchases need the purchase claim flow, not `capture`. See
[Mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Next
- [iOS SDK reference](/analytics/sdks/ios)
- [Identity and contacts](/analytics/concepts/identity-and-contacts)
# Quickstart: RN / Expo (https://www.getfounderhq.com/docs/analytics/getting-started/quickstart-react-native-expo)
Install the SDK, create the client once, and see your first event in
FounderHQ. About 5 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once.
## 1. Install the SDK
```bash
npx expo install @founderhq/events-react-native \
@react-native-async-storage/async-storage \
expo-application expo-device expo-localization
```
The three Expo packages let the SDK report app version, device model,
locale, and time zone. Building bare React Native? Install
`@founderhq/events-react-native` and
`@react-native-async-storage/async-storage` with npm, and skip them.
## 2. Create the client once
Create one module and import it everywhere.
```ts
// lib/analytics.ts
import { createFounderHqExpoClient } from "@founderhq/events-react-native/expo";
export const events = createFounderHqExpoClient("fhq_pk_XXXX");
```
Bare React Native uses the same API without the Expo context:
```ts
import { FounderHqReactNativeClient } from "@founderhq/events-react-native";
export const events = new FounderHqReactNativeClient("fhq_pk_XXXX");
```
App lifecycle, sessions, and on-device queuing start immediately. The
queue survives a restart, so events captured offline still arrive.
## 3. Track screens
Expo Router:
```tsx
import { usePathname } from "expo-router";
import { useEffect, useMemo } from "react";
import { createExpoRouterTracker } from "@founderhq/events-react-native";
import { events } from "./lib/analytics";
export function ScreenTracker() {
const pathname = usePathname();
const trackScreen = useMemo(() => createExpoRouterTracker(events), []);
useEffect(() => {
trackScreen(pathname);
}, [pathname, trackScreen]);
return null;
}
```
React Navigation instead? Pass your `navigationRef` to
`createReactNavigationTracker(events, navigationRef)` and spread the
returned `onReady` and `onStateChange` onto ``.
## 4. Identify and capture
```ts
events.identify("user_8421", { email: "jane@acme.com", plan: "pro" });
events.capture("trial.started", { plan: "pro" });
```
Pass your own stable user ID, not an email. Call `events.reset()` on
sign-out.
## Verify
1. Run the app on a simulator or device and trigger the event.
2. In FounderHQ, open **Sequences → Events**.
3. You see `trial.started` with `plan: pro`, a `$session_start`, and a
`$screen` for the screen you opened.
4. Open **Sequences → Contacts**. Jane is there with her email.
Events can take a few seconds to appear. The SDK batches them. To send
immediately, `await events.flush()`.
## Troubleshoot
### Events only appear after a restart
That is the queue doing its job. It flushes on a timer, on batch size,
and when the app goes to the background.
### Purchases are not attributed
In-app purchases need the purchase claim flow, not `capture`. See
[Mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Next
- [React Native SDK reference](/analytics/sdks/react-native)
- [Identity and contacts](/analytics/concepts/identity-and-contacts)
# Quickstart: Server (Node) (https://www.getfounderhq.com/docs/analytics/getting-started/quickstart-server-node)
Send an event your server knows about, and see it on the contact in
FounderHQ. About 5 minutes.
You need a FounderHQ account and a secret key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Backend**, and copy it. The full key is shown only once. Keep it on
your server, never in a browser or a mobile app.
Use the Node SDK for facts only your backend can prove: a signup that
passed validation, a plan change, a webhook you received. Use a client
SDK for what people do in the interface.
## 1. Install the package
```npm
npm install @founderhq/events-node
```
## 2. Create one client for your process
```ts
// lib/analytics.ts
import { FounderHqNode } from "@founderhq/events-node";
export const events = new FounderHqNode(process.env.FOUNDERHQ_SECRET_KEY!);
```
Put `FOUNDERHQ_SECRET_KEY=fhq_sk_XXXX` in your server environment. The
client refuses a publishable key, so a mix-up fails at startup rather
than in production.
## 3. Capture a server-truth event
Every server event names the contact it belongs to. Pass your own user
ID, an email, or a phone number — at least one is required.
```ts
events.capture({
contact: { externalId: "user_8421", email: "jane@acme.com" },
event: "subscription.upgraded",
properties: { plan: "growth", mrr: 99 },
});
```
Use the same `externalId` you pass to `identify` in the browser or the
app. That is what keeps one contact instead of two.
Retrying a webhook? Pass a stable `idempotencyKey` so the same delivery
counts once.
```ts
events.capture({
contact: { externalId: "user_8421" },
event: "subscription.upgraded",
properties: { plan: "growth" },
idempotencyKey: "stripe_evt_1a2b3c",
});
```
## 4. Flush before the process ends
The client batches in the background. Serverless functions and scripts
exit too fast for that, so flush explicitly.
```ts
await events.flush();
```
Call `await events.shutdown()` when your server stops, to send what is
left and stop the timer.
## Verify
1. Run the code path that captures the event.
2. In FounderHQ, open **Sequences → Contacts** and open Jane.
3. You see `subscription.upgraded` with `plan: growth` on her timeline.
4. **Sequences → Events** shows the same event in the live stream.
## Troubleshoot
### The event never arrives
Pass an `onError` handler when you create the client. It reports every
rejected or dropped event with the reason.
```ts
export const events = new FounderHqNode(process.env.FOUNDERHQ_SECRET_KEY!, {
onError: (error, dropped) => console.error(error, dropped),
});
```
### A second contact appeared for the same person
Your server and your client are using different IDs. Both must use the
same stable user ID. See
[Identity and contacts](/analytics/concepts/identity-and-contacts).
### An event starting with `$` is ignored
Names that start with `$` are reserved for the SDKs. Send your own
name instead. Revenue has its own API — see [Revenue](/analytics/revenue).
## Next
- [Node SDK reference](/analytics/sdks/node)
- [Accounts and groups](/analytics/concepts/accounts-and-groups)
# Quickstart: Web (npm) (https://www.getfounderhq.com/docs/analytics/getting-started/quickstart-web-npm)
Install the package, initialize it once, and see your first event in
FounderHQ. About 5 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once.
## 1. Install the package
```npm
npm install @founderhq/events
```
## 2. Initialize once, at your app's entry point
```ts
import { founderhq } from "@founderhq/events";
founderhq.init("fhq_pk_XXXX");
```
`founderhq` is a shared client. Import it anywhere. Calling `init` a
second time does nothing, so a hot reload is safe.
From here on, pageviews, page leaves, sessions, clicks, rage clicks,
dead clicks, outbound clicks, scroll depth, and Core Web Vitals are
captured for you. Single-page navigations count as pageviews.
Next.js App Router, or any framework that renders on the server? Put
the call in a client component and render it once in your root layout.
```tsx
"use client";
import { useEffect } from "react";
import { founderhq } from "@founderhq/events";
export function FounderHqAnalytics() {
useEffect(() => {
founderhq.init("fhq_pk_XXXX");
}, []);
return null;
}
```
## 3. Identify the person when they sign in
```ts
founderhq.identify("user_8421", {
email: "jane@acme.com",
plan: "pro",
});
```
Pass your own stable user ID, not an email. FounderHQ merges the
visitor's anonymous history into that contact.
Call `founderhq.reset()` when the person signs out, so the next visitor
on that browser starts clean.
## 4. Capture your first event
```ts
founderhq.capture("trial.started", { plan: "pro" });
```
Use lowercase names you will still understand in six months. Event
names that start with `$` are reserved.
## Verify
1. Run your app and trigger the event.
2. In FounderHQ, open **Sequences → Events**.
3. You see `trial.started` with `plan: pro`, plus a `$pageview`.
4. Open **Sequences → Contacts**. Jane is there with her email.
Events can take a few seconds to appear. The SDK batches them. To send
immediately, `await founderhq.flush()`.
## Troubleshoot
### Nothing appears in Events
Check the network tab. If requests to `/i/v2/e` fail with a 401 or 403,
the key is wrong or your origin is not allowed. Check **Allowed
origins** on the key in **Sequences → Settings → Event API keys**.
### Events fire twice in development
React Strict Mode runs effects twice. `init` is safe to call twice, but
a `capture` inside an effect is not. Capture on the user's action, not
on render.
## Next
- [Identity and contacts](/analytics/concepts/identity-and-contacts)
- [Send server-side events](/analytics/getting-started/quickstart-server-node)
- [Web SDK options](/analytics/sdks/web)
# Quickstart: Web (snippet) (https://www.getfounderhq.com/docs/analytics/getting-started/quickstart-web-snippet)
Add two script tags and see your first event in FounderHQ. About
2 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once.
Use this page when you can edit your site's HTML. If you build with
npm, use [Quickstart: Web (npm)](/analytics/getting-started/quickstart-web-npm)
instead.
## 1. Load the SDK
Paste this into your ``, on every page you want to measure.
```html
```
The script defines a global called `founderhq`. It also defines
`FounderHQEvents`, and `FounderHQEvents.founderhq` is the same object.
From here on, pageviews, page leaves, sessions, clicks, rage clicks,
dead clicks, outbound clicks, scroll depth, and Core Web Vitals are
captured for you.
## 2. Identify the person when they sign in
Call `identify` with your own user ID after a sign-in or sign-up.
```html
```
FounderHQ merges the visitor's anonymous history into that contact.
## 3. Capture your first event
Capture a fact that matters to your business.
```html
```
Use lowercase names you will still understand in six months. Event
names that start with `$` are reserved.
## Verify
1. Load a page on your site and click the button that fires the event.
2. In FounderHQ, open **Sequences → Events**.
3. You see `trial.started` with `plan: pro`, plus a `$pageview` for the
page you loaded.
4. Open **Sequences → Contacts**. Jane is there with her email.
Events can take a few seconds to appear. The SDK batches them.
## Troubleshoot
### Nothing appears in Events
Open your browser console. If requests to `/i/v2/e` fail with a 401 or
403, the key is wrong or your site's origin is not allowed. Check the
key you pasted, and check **Allowed origins** on the key in
**Sequences → Settings → Event API keys**.
### The person shows as a guest
`identify` must run after the person signs in, with a stable ID from
your database. Do not pass an email as the ID. See
[Identity and contacts](/analytics/concepts/identity-and-contacts).
### You see no bot traffic
That is deliberate. FounderHQ keeps crawler visits out of your metrics
and your bill. See [Bot traffic policy](/analytics/concepts/bot-traffic-policy).
# What FounderHQ analytics is (https://www.getfounderhq.com/docs/analytics/getting-started/what-founderhq-analytics-is)
FounderHQ analytics answers three questions: what people do in your
product, who those people are, and which channel paid you.
## One protocol, five SDKs
Web, React Native and Expo, iOS, Android, and Node all speak the same
event protocol. An event you send from a phone and an event you send
from your server land in the same stream, with the same property names.
You learn the model once.
## What you capture
You capture events. An event is one fact: `trial.started`,
`invite.sent`, `export.completed`. You add properties to it.
On the web, the SDK also captures common facts for you: pageviews, page
leaves, sessions, clicks, rage clicks, dead clicks, outbound clicks,
scroll depth, and Core Web Vitals. On mobile, it captures app lifecycle
and screens. You can turn each one off. See
[Sessions](/analytics/concepts/sessions).
## Who did it
Every visitor starts anonymous. When someone signs in, you call
`identify` with your own user ID. FounderHQ merges the anonymous history
into that person and keeps one contact for them across devices and
subdomains. Contacts feed segments and sequences in the app. See
[Identity and contacts](/analytics/concepts/identity-and-contacts).
If your product is sold to companies, you can attach an account to
events, so revenue and touches belong to the workspace, not one seat.
See [Accounts and groups](/analytics/concepts/accounts-and-groups).
## Which channel paid you
The SDK reads campaign parameters and ad click IDs from the URL and
keeps them with the visitor. When a payment arrives from Stripe, Dodo,
Apple, Google Play, or your own revenue call, FounderHQ answers two
questions for that payment: which touch **introduced** the customer, and
which touch **closed** them. See
[Attribution](/analytics/concepts/attribution).
## What you do not have to handle
- **Bots.** FounderHQ classifies crawler traffic on the server. Bot
visits never create contacts, never move your metrics, and never count
against billing. See [Bot traffic policy](/analytics/concepts/bot-traffic-policy).
- **Consent.** The SDK has granted, denied, and off states, respects Do
Not Track when you ask it to, and stops on `optOut`. See
[Consent and privacy](/analytics/concepts/consent-and-privacy).
- **Offline and flaky networks.** Client SDKs queue events on the device
and retry.
No SDK collects advertising IDs, contacts, input values, page text, DOM
snapshots, console logs, or network bodies.
## Two kinds of key
A publishable key (`fhq_pk_XXXX`) is safe in browsers and mobile apps. A
secret key (`fhq_sk_XXXX`) belongs on your server only. The Node SDK
refuses a publishable key.
Ready? Start with a [quickstart](/analytics/getting-started).
# Android (https://www.getfounderhq.com/docs/analytics/sdks/android)
Capture screens, app lifecycle, and your own events from a Kotlin app.
You need a publishable key (`fhq_pk_...`) for the brand you want to
measure. See [Getting started](/analytics/getting-started).
## Install
```kotlin
dependencies {
implementation("com.getfounderhq:events:0.8.0")
// Navigation Compose screens
implementation("com.getfounderhq:events-compose:0.8.0")
}
```
The SDK needs `minSdk` 24 and Java 17.
## Initialize
Create one client in your `Application`, and share it.
```kotlin
class MyApplication : Application() {
lateinit var events: FounderHQEvents
override fun onCreate() {
super.onCreate()
events = FounderHQEvents(this, "fhq_pk_XXXX")
}
}
```
Pass a config when you want to change the defaults:
```kotlin
events = FounderHQEvents(
this,
"fhq_pk_XXXX",
FounderHQEventsConfig(captureScreens = true),
)
```
The client registers activity lifecycle callbacks, captures activity
screens and application lifecycle, and records the session start.
## Capture events
```kotlin
events.capture("trial.started", mapOf("plan" to "growth"))
```
`capture` waits for startup to finish, then queues the event. Call
`readyForCapture()` when you want to wait for startup on its own.
Record a deep link when your app opens from one:
```kotlin
events.captureDeepLink(url)
```
`captureDeepLink` drops the query and fragment, and keeps the campaign
parameters it recognizes. After you read Play Install Referrer data, pass
it on:
```kotlin
events.captureInstallReferrer(mapOf("utm_source" to "google_play"))
```
## Track screens
Activity screens are captured for you. Call `events.screen("Pricing")`
anywhere else.
With Navigation Compose, add the observer inside your `NavHost` scope:
```kotlin
import com.founderhq.events.compose.FounderHQNavigationObserver
FounderHQNavigationObserver(navController = navController, events = events)
```
It records the current route, and skips a repeat of the route the app is
already on.
## Identify a contact
```kotlin
events.identify("user_42", mapOf("email" to "jane@acme.com"))
```
Change identity and account in one call:
```kotlin
events.identify(
"user_42",
mapOf("email" to "jane@acme.com"),
FounderHQAccountContext("workspace_123"),
)
```
Update contact properties on their own with `setPersonProperties(set,
setOnce)`. With the default `IDENTIFIED_ONLY` mode, changes made before
`identify` stay on the device and go out with the identify call.
Call `reset()` on logout. It clears the identity and the account.
## Accounts
```kotlin
events.setAccount("workspace_123", mapOf("plan" to "growth"))
events.setAccountProperties(mapOf("seats" to 12))
events.clearAccount() // resetAccounts() does the same
```
`setAccount` also takes a `FounderHQAccountContext`, which carries `key`,
`properties`, and `contextToken`. Set `account` in the config when the first
automatic event must already carry it. An account change rotates the
account span only. The person session and queued events are untouched. See
[Accounts and groups](/analytics/concepts/accounts-and-groups).
## Consent
| You call | What happens |
| --- | --- |
| `optIn()` | Capture is on |
| `optOut()` | Capture stops, and the queue is cleared |
| `isOptedOut()` | Returns `true` while capture is off |
Set `optOutByDefault = true` in the config to start silent until the person
agrees. See [Consent and privacy](/analytics/concepts/consent-and-privacy).
The SDK never collects advertising identifiers.
## Purchases
The client can tie a Play Billing or RevenueCat purchase to the person who
made it.
| Method | What it does |
| --- | --- |
| `purchaseAttribution()` | Returns the identifiers to pass to the store or to RevenueCat |
| `preparePurchase(source, completion)` | Records intent before checkout, and returns the purchase context |
| `applyPurchaseContext(builder, prepared)` | Puts the context on your Play Billing flow |
| `observePurchase(purchase, prepared)` | Reports the purchase that followed |
| `claimSubscription(purchase, confirmation)` | Moves future revenue of an existing subscription |
The full flow, including what each store needs, is on
[Mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Config
`FounderHQEventsConfig` takes these values.
| Value | Type | Default | What it does |
| --- | --- | --- | --- |
| `host` | `String` | `https://i.getfounderhq.com` | Where events are sent |
| `flushAt` | `Int` | `20` | Sends a batch once this many events are queued |
| `flushIntervalSeconds` | `Long` | `10` | Sends a batch on this timer. `0` turns the timer off |
| `personProfiles` | `PersonProfiles` | `IDENTIFIED_ONLY` | When contact property changes are applied |
| `optOutByDefault` | `Boolean` | `false` | Starts opted out |
| `captureLifecycle` | `Boolean` | `true` | App opened and backgrounded events |
| `captureScreens` | `Boolean` | `true` | Activity screen events |
| `captureSessions` | `Boolean` | `true` | Session start events |
| `captureInstallUpdates` | `Boolean` | `true` | App installed and updated events |
| `remoteConfig` | `Boolean` | `true` | Reads capture settings you set in FounderHQ |
| `account` | `FounderHQAccountContext?` | `null` | Installs account context before the first event |
| `purchasePrepareTimeoutMillis` | `Long` | `3000` | How long `preparePurchase` waits before checkout goes ahead offline |
| `captureElementInteractions` | `Boolean` | `false` | Taps and rage taps. See [Element interactions](#element-interactions) |
| `capturePushNotificationOpened` | `Boolean` | `true` | Records the notification taps your app reports |
| `tracingHeaders` | `List?` | `null` | Hostnames whose requests carry the session id |
| `maxQueueSize` | `Int` | `1000` | Events kept offline before the oldest are dropped |
| `eventTtlMillis` | `Long` | `86400000` | How long an unsent event may wait |
| `maxRetries` | `Int` | `5` | Times a failed event is retried |
| `debug` | `Boolean` | `false` | Logs what the SDK drops or refuses |
`FounderHQEventsDependencies` replaces the clock, UUID source, storage,
transport, and platform facts. Use it in tests.
## Sending
| Method | Returns | What it does |
| --- | --- | --- |
| `flush()` | `Boolean` | Sends queued events now, and tells you whether the queue drained |
| `close()` | — | Flushes, stops the executors, and unregisters the lifecycle callbacks |
| `getDistinctId()` | `String` | The current identity |
| `getSessionId()` | `String` | The current session |
`FounderHQEvents` implements `AutoCloseable`.
## Element interactions
Set `captureElementInteractions` to `true`. The SDK then records
`$autocapture` when someone taps a control, and `$rageclick` when they tap the
same control three times in a second. Both are off by default.
Each event carries the control and up to four of its parents.
| Stored | Not stored |
| --- | --- |
| The view class, resource entry name, and content description | Anything a person typed |
| The path through the view hierarchy | Tap coordinates |
| The label a `Button` shows, or the selected tab of a `TabLayout` | The contents or hint of an `EditText` |
A tap on an `EditText` records nothing at all, and a plain `TextView` never
contributes its words. Give a view the tag `android:tag="fhq-no-capture"` to
skip it and everything inside it.
Labels are cut to 255 characters. Numbers that look like a card number or a
social security number are removed from them.
Only a tap counts. A finger that moves further than the platform's touch slop,
a second finger, or a cancelled touch means the gesture was a scroll. Lifting
a finger over a row at the end of a scroll records nothing.
## Remote config
The client reads your capture settings from FounderHQ at startup, caches
them in its storage, and applies the cached copy on the next launch.
| Key | What it turns on and off |
| --- | --- |
| `capture_sessions` | Session start events |
| `capture_screens` | Screen events |
| `capture_lifecycle` | App opened and backgrounded events |
| `autocapture` | Taps and rage taps |
| `capture_rageclicks` | Rage taps only. Taps are still recorded |
Your settings in FounderHQ win. They can turn capture off for every install,
and on for an app that shipped with the wrong value. You cannot rebuild an app
people have already installed, so the dashboard decides. The values in your
config apply until your settings arrive.
Events already queued under a key that turns off are dropped before they leave
the device.
Call `refreshRemoteConfig()` to fetch them again while the app runs. Call
`applyRemoteConfig(remote)` to apply settings you supply yourself. Set
`remoteConfig = false` in the config to skip the request.
## Next
Read the [protocol reference](/analytics/protocol-reference) for the wire
format, the reserved event names, and the campaign properties a deep link
can carry.
# SDKs (https://www.getfounderhq.com/docs/analytics/sdks)
One capture protocol, five SDKs. Pick the one that matches where your
code runs, then follow that page.
You need a brand and its keys. Copy a publishable key (`fhq_pk_...`) for
browsers and apps. Copy a secret key (`fhq_sk_...`) for servers. See
[Getting started](/analytics/getting-started).
## Pick an SDK
| SDK | Package | Key | Use it for |
| --- | --- | --- | --- |
| [Web](/analytics/sdks/web) | `@founderhq/events` | publishable | Websites and web apps |
| [React Native](/analytics/sdks/react-native) | `@founderhq/events-react-native` | publishable | React Native and Expo apps |
| [iOS](/analytics/sdks/ios) | `FounderHQEvents` (SwiftPM, CocoaPods) | publishable | Swift and SwiftUI apps |
| [Android](/analytics/sdks/android) | `com.getfounderhq:events` | publishable | Kotlin and Compose apps |
| [Node](/analytics/sdks/node) | `@founderhq/events-node` | secret | Your backend |
## What every client SDK does
The web, React Native, iOS, and Android SDKs share one surface.
| You call | It does |
| --- | --- |
| `capture` | Records one event with your own properties |
| `identify` | Ties the visitor to a contact you know |
| `setAccount` | Says which account the activity belongs to |
| `setPersonProperties` | Updates contact properties |
| `register` / `registerOnce` / `unregister` | Adds properties to every later event |
| `optIn` / `optOut` / `isOptedOut` | Controls capture consent on the device |
| `getDistinctId` / `getSessionId` | Reads the current identity and session |
| `reset` | Clears identity and account on logout |
| `flush` / `close` | Sends queued events now |
Every client SDK queues events, persists them across restarts, retries
failures, and sends them in batches. Each one also captures screens or
pageviews, sessions, and app or page lifecycle on its own.
## What the Node SDK does instead
The Node SDK carries no device state. It has no sessions, no automatic
capture, and no consent switches. You name the contact on every call, and
you use a secret key. It is also the only SDK that can record account
membership changes and send revenue commands.
## The protocol underneath
Client SDKs send `POST /i/v2/e` with a publishable key. The Node SDK sends
`POST /api/events` with a secret key. Both use one property namespace, one
event shape, and per-event acknowledgements.
Read the [protocol reference](/analytics/protocol-reference) when you need the
wire format, the reserved event names, or the campaign property list.
# iOS (https://www.getfounderhq.com/docs/analytics/sdks/ios)
Capture screens, app lifecycle, and your own events from a Swift app.
You need a publishable key (`fhq_pk_...`) for the brand you want to
measure. See [Getting started](/analytics/getting-started).
## Install
In Xcode, add `https://github.com/FounderHQ/founderhq-events-ios` with
Swift Package Manager and select version **0.8.0** or later.
For CocoaPods:
```ruby
pod 'FounderHQEvents', '~> 0.8.0'
```
You can also install directly from the Git release:
```ruby
pod 'FounderHQEvents', :git => 'https://github.com/FounderHQ/founderhq-events-ios.git', :tag => 'v0.8.0'
```
The SDK needs iOS 15 or macOS 12, and Swift 5.9.
## Initialize
Create one client and keep it for the life of the app.
```swift
import FounderHQEvents
let events = FounderHQEvents(apiKey: "fhq_pk_XXXX")
```
Pass a configuration when you want to change the defaults:
```swift
let events = FounderHQEvents(
apiKey: "fhq_pk_XXXX",
configuration: .init(
personProfiles: .identifiedOnly,
captureScreens: true
)
)
```
The client captures UIKit screens and application lifecycle on its own, and
records the session start.
## Capture events
```swift
events.capture("trial.started", properties: ["plan": "growth"])
```
`capture` returns at once and does the work in the background. Every
writing method has an `...AndWait` twin that you can `await`:
`captureAndWait`, `identifyAndWait`, `screenAndWait`, `setAccountAndWait`,
`clearAccountAndWait`, `setAccountPropertiesAndWait`,
`setPersonPropertiesAndWait`, `registerAndWait`, `registerOnceAndWait`,
`unregisterAndWait`, `optInAndWait`, `optOutAndWait`, and `resetAndWait`.
`readyForCapture()` resolves once startup and the queued calls are done.
Record a deep link when your app opens from one:
```swift
events.captureDeepLink(url)
```
`captureDeepLink` drops the query and fragment, and keeps the campaign
parameters it recognizes.
## Track screens
UIKit screens are captured for you. In SwiftUI, mark the view:
```swift
PricingView()
.founderHQScreen("Pricing", client: events)
```
Call `events.screen("Pricing")` directly anywhere else.
## Identify a contact
```swift
events.identify("user_42", properties: ["email": "jane@acme.com"])
```
Change identity and account in one call:
```swift
events.identify(
"user_42",
properties: ["email": "jane@acme.com"],
account: FounderHQAccountContext(key: "workspace_123")
)
```
Update contact properties on their own with
`setPersonProperties(_:setOnce:)`. With the default `identifiedOnly` mode,
changes made before `identify` stay on the device and go out with the
identify call.
Call `reset()` on logout. It clears the identity and the account.
## Accounts
```swift
events.setAccount("workspace_123", properties: ["plan": "growth"])
events.setAccountProperties(["seats": 12])
events.clearAccount() // resetAccounts() does the same
```
`setAccount` also takes a `FounderHQAccountContext`, which carries `key`,
`properties`, and `contextToken`. Set `account` in the configuration when
the first automatic event must already carry it. An account change rotates
the account span only. The person session and queued events are untouched.
See [Accounts and groups](/analytics/concepts/accounts-and-groups).
## Consent
| You call | What happens |
| --- | --- |
| `optIn()` | Capture is on |
| `optOut()` | Capture stops |
| `isOptedOut()` | Returns `true` while capture is off |
Set `optOutByDefault: true` in the configuration to start silent until the
person agrees. See
[Consent and privacy](/analytics/concepts/consent-and-privacy).
The SDK never collects advertising identifiers.
## Purchases
The client can tie a StoreKit or RevenueCat purchase to the person who made
it.
| Method | What it does |
| --- | --- |
| `purchaseAttribution()` | Returns the identifiers to pass to the store or to RevenueCat |
| `preparePurchase(source:)` | Records intent before checkout, and returns the purchase context |
| `purchase(_:options:)` | Runs a StoreKit purchase with the context already attached |
| `observePurchase(_:prepared:)` | Reports the purchase that followed |
| `claimSubscription(_:confirmation:)` | Moves future revenue of an existing subscription |
The full flow, including what each store needs, is on
[Mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Configuration
`FounderHQEventsConfiguration` takes these values.
| Value | Type | Default | What it does |
| --- | --- | --- | --- |
| `host` | `URL` | `https://i.getfounderhq.com` | Where events are sent |
| `flushAt` | `Int` | `20` | Sends a batch once this many events are queued |
| `flushInterval` | `TimeInterval` | `10` | Sends a batch on this timer, in seconds |
| `personProfiles` | `FounderHQPersonProfiles` | `.identifiedOnly` | When contact property changes are applied |
| `optOutByDefault` | `Bool` | `false` | Starts opted out |
| `captureLifecycle` | `Bool` | `true` | App opened and backgrounded events |
| `captureScreens` | `Bool` | `true` | UIKit screen events |
| `captureSessions` | `Bool` | `true` | Session start events |
| `captureInstallUpdates` | `Bool` | `true` | App installed and updated events |
| `remoteConfig` | `Bool` | `true` | Reads capture settings you set in FounderHQ |
| `account` | `FounderHQAccountContext?` | `nil` | Installs account context before the first event |
| `purchasePrepareTimeout` | `TimeInterval` | `3` | How long `preparePurchase` waits before checkout goes ahead offline |
| `captureElementInteractions` | `Bool` | `false` | Taps and rage taps. See [Element interactions](#element-interactions) |
| `capturePushNotificationOpened` | `Bool` | `true` | Records when someone taps one of your notifications |
| `tracingHeaders` | `[String]?` | `nil` | Hostnames whose requests carry the session id |
| `maxQueueSize` | `Int` | `1000` | Events kept offline before the oldest are dropped |
| `eventTTL` | `TimeInterval` | `86400` | How long an unsent event may wait, in seconds |
| `maxRetries` | `Int` | `5` | Times a failed event is retried |
| `debug` | `Bool` | `false` | Logs what the SDK drops or refuses |
`FounderHQEventsDependencies` replaces the clock, UUID source, storage,
transport, platform facts, and screen-capture installer. Use it in tests.
## Sending
| Method | Returns | What it does |
| --- | --- | --- |
| `flush()` | `Bool` | Sends queued events now, and tells you whether the queue drained |
| `close()` | — | Flushes, stops the timer, and removes the lifecycle observers |
| `getDistinctId()` | `String` | The current identity |
| `getSessionId()` | `String` | The current session |
## Element interactions
Set `captureElementInteractions` to `true`. The SDK then records
`$autocapture` when someone taps a control, and `$rageclick` when they tap the
same control three times in a second. Both are off by default.
Each event carries the control and up to four of its parents.
| Stored | Not stored |
| --- | --- |
| The class, accessibility identifier, and accessibility label | Anything a person typed |
| The action, the enabled state, and the selected state | Tap coordinates |
| The title a `UIButton`, `UIBarButtonItem`, or `UISegmentedControl` shows | The contents of a `UITextField`, `UITextView`, or `UISearchBar` |
Text fields, text views, and search bars are skipped. A tap on one records
nothing at all.
Titles are cut to 255 characters. Numbers that look like a card number or a
social security number are removed from them.
## Remote config
The client reads your capture settings from FounderHQ at startup, caches
them in its storage, and applies the cached copy on the next launch.
| Key | What it turns on and off |
| --- | --- |
| `capture_sessions` | Session start events |
| `capture_screens` | Screen events |
| `capture_lifecycle` | App opened and backgrounded events |
| `autocapture` | Taps and rage taps |
| `capture_rageclicks` | Rage taps only. Taps are still recorded |
Your settings in FounderHQ win. They can turn capture off for every install,
and on for an app that shipped with the wrong value. You cannot rebuild an app
people have already installed, so the dashboard decides. The values in your
configuration apply until your settings arrive.
Events already queued under a key that turns off are dropped before they
leave the device.
Call `refreshRemoteConfig()` to fetch them again while the app runs. Call
`applyRemoteConfig(_:)` to apply settings you supply yourself. Set
`remoteConfig: false` in the configuration to skip the request.
## Next
Read the [protocol reference](/analytics/protocol-reference) for the wire
format, the reserved event names, and the campaign properties a deep link
can carry.
# Node (https://www.getfounderhq.com/docs/analytics/sdks/node)
Send the events only your backend knows about: signups, plan changes,
payments, and account membership.
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).
## 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` | Sends one revenue command |
| `flush()` | `Promise` | Sends every queued event, in order |
| `shutdown()` | `Promise` | 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.
# React Native (https://www.getfounderhq.com/docs/analytics/sdks/react-native)
Capture screens, app lifecycle, and your own events from a React Native or
Expo app.
You need a publishable key (`fhq_pk_...`) for the brand you want to
measure. See [Getting started](/analytics/getting-started).
## Install
```npm
npm install @founderhq/events-react-native @react-native-async-storage/async-storage
```
The SDK needs React Native 0.73 or later and AsyncStorage 1.21 or later.
For the Expo build, add the three optional packages it reads context from:
```bash
npx expo install expo-application expo-device expo-localization
```
## Initialize
Create one client and share it across the app.
```ts
import { FounderHqReactNativeClient } from "@founderhq/events-react-native";
export const events = new FounderHqReactNativeClient("fhq_pk_XXXX");
```
On Expo, use the Expo client instead. It adds app, device, and locale
context for you.
```ts
import { createFounderHqExpoClient } from "@founderhq/events-react-native/expo";
export const events = createFounderHqExpoClient("fhq_pk_XXXX");
```
The client loads stored state, applies your capture settings, starts app
lifecycle capture, and records the session start on its own.
## Capture events
```ts
events.capture("trial.started", { plan: "growth" });
```
`track` is an alias for `capture`. Both queue the work and return at once.
Every writing method has an `...AndWait` twin that resolves after the work
is applied: `captureAndWait`, `identifyAndWait`, `screenAndWait`,
`setAccountAndWait`, `clearAccountAndWait`, `resetAccountsAndWait`,
`setAccountPropertiesAndWait`, `setPersonPropertiesAndWait`, and
`resetAndWait`. Use them in tests, and before the app leaves a flow.
`readyForCapture()` resolves once startup and the queued calls are done.
Record a deep link when your app opens from one:
```ts
events.captureDeepLink(url);
```
`captureDeepLink` records the link without its query string, and keeps the
campaign parameters it recognizes.
## Track screens
Record a screen yourself with `events.screen("Pricing")`, or wire up your
router.
React Navigation:
```tsx
import { createReactNavigationTracker } from "@founderhq/events-react-native";
const tracker = createReactNavigationTracker(events, navigationRef);
;
```
Expo Router:
```ts
import { createExpoRouterTracker } from "@founderhq/events-react-native";
const trackPath = createExpoRouterTracker(events);
trackPath(pathname, params);
```
Both trackers skip a repeat of the screen the app is already on.
## Identify a contact
```ts
events.identify("user_42", { email: "jane@acme.com" });
```
Pass an account in the third argument to change identity and account
together:
```ts
await events.identifyAndWait("user_42", { email: "jane@acme.com" }, {
account: { key: "workspace_123" },
});
```
Update contact properties on their own with `setPersonProperties(set,
setOnce)`. With the default `identified_only` mode, changes made before
`identify` stay on the device and go out with the identify call.
Call `reset()` on logout. It clears the identity and the account.
## Accounts
```ts
events.setAccount("workspace_123");
events.setAccountProperties({ plan: "growth", seats: 12 });
events.clearAccount(); // resetAccounts() does the same
```
`setAccount` also takes an object with `key`, `properties`, and
`contextToken`. Pass `account` to the constructor when the first automatic
event must already carry it. A queued event keeps the account it was
captured with. See
[Accounts and groups](/analytics/concepts/accounts-and-groups).
## Consent
| You call | What happens |
| --- | --- |
| `optIn()` | Capture is on |
| `optOut()` | Capture stops, and the queue is cleared |
| `isOptedOut()` | Returns `true` while capture is off |
Set `opt_out_by_default: true` to start silent until the person agrees. See
[Consent and privacy](/analytics/concepts/consent-and-privacy).
## Purchases
The client can tie an App Store, Google Play, or RevenueCat purchase to the
person who made it.
| Method | What it does |
| --- | --- |
| `purchaseAttribution()` | Returns the identifiers to pass to the store or to RevenueCat |
| `preparePurchase({ source })` | Records intent before checkout, and returns the purchase context |
| `observePurchase({ prepared, purchase })` | Reports the purchase that followed |
| `claimSubscription({ purchase, confirmation })` | Moves future revenue of an existing subscription |
The full flow, including what each store needs, is on
[Mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Options
Pass these in the second argument to the constructor.
| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `host` | `string` | `https://i.getfounderhq.com` | Where events are sent |
| `flush_at` | `number` | `20` | Sends a batch once this many events are queued |
| `flush_interval_ms` | `number` | `10000` | Sends a batch on this timer. `0` turns the timer off |
| `storage` | adapter | AsyncStorage | Your own storage adapter |
| `person_profiles` | `"identified_only"` \| `"always"` \| `"never"` | `"identified_only"` | When contact property changes are applied |
| `opt_out_by_default` | `boolean` | `false` | Starts opted out |
| `capture_lifecycle` | `boolean` | `true` | App opened and backgrounded events |
| `capture_screens` | `boolean` | `true` | Screen events |
| `capture_sessions` | `boolean` | `true` | Session start events |
| `remote_config` | `boolean` | `true` | Reads capture settings you set in FounderHQ |
| `context` | `object` | none | Your own values, added to every event |
| `contextProvider` | `() => object` | none | The same, resolved at capture time |
| `account` | `string` \| object \| `null` | none | Installs account context before the first event |
| `revenueCat` | object | none | Your RevenueCat client, kept in step with the identity |
| `purchase_prepare_timeout_ms` | `number` | `3000` | How long `preparePurchase` waits before checkout goes ahead offline |
| `capture_element_interactions` | `boolean` | `false` | Taps and rage taps. See [Element interactions](#element-interactions) |
| `capture_element_text` | `boolean` | `true` | Records the words a control shows |
| `capture_rageclicks` | `boolean` | `true` | Rage taps, when element interactions are on |
| `auto_element_capture` | `boolean` | `true` | Watches taps from the app root |
## Lifecycle and sending
The client sends a batch when the queue reaches `flush_at`, on the flush
timer, and when the app goes to the background.
| Method | Returns | What it does |
| --- | --- | --- |
| `flush()` | `Promise` | Sends queued events now |
| `close()` | `Promise` | Flushes, then stops the timer and the lifecycle listener |
| `startLifecycleCapture()` | — | Re-attaches the lifecycle listener after `close()` |
| `getDistinctId()` | `string` | The current identity |
| `getSessionId()` | `string` | The current session |
## Element interactions
Set `capture_element_interactions` to `true`. The SDK then records
`$autocapture` when someone taps a control, and `$rageclick` when they tap the
same control three times in a second. Both are off by default.
```ts
const events = new FounderHqReactNativeClient("fhq_pk_...", {
capture_element_interactions: true,
});
```
You add nothing else. The SDK watches taps from the root of every screen, so
it needs no code in your components. Your layout does not change, and a root
wrapper of your own keeps working.
Create the client in the module your entry file imports. A client created
inside a component starts too late to watch the root. Set
`auto_element_capture` to `false` in that case, and pass
`elementCaptureProps()` to a view yourself.
A `Modal` is the one place the root cannot see. React Native gives a modal its
own window, so taps inside it never reach a view outside it. Pass
`elementCaptureProps()` to the modal's own view:
```tsx
```
Those props only watch. They never take the touch, so every control below
behaves as it did before.
Each event carries the control and up to four of its parents.
| Stored | Not stored |
| --- | --- |
| The component name, `testID`, and `accessibilityLabel` | Anything a person typed |
| The `accessibilityRole` | Tap coordinates |
| The words the control shows | The value, placeholder, or contents of a `TextInput` |
A tap on a `TextInput`, or on any component with `value`, `defaultValue`,
`placeholder`, `onChangeText`, or `secureTextEntry`, records nothing at all. A
field inside a button adds nothing to that button's text, so a Save button
around an input reads as `Save`.
Text is cut to 255 characters. Numbers that look like a card number or a
social security number are removed from it. Add `fhqNoCapture` to a component
to skip it and everything inside it. Set `capture_element_text` to `false` to
record the controls without their words.
Minified builds rename components. Set `testID` on the controls you want to
recognize in your reports.
## Remote config
The client reads your capture settings from FounderHQ at startup, caches
them on the device, and applies the cached copy on the next launch. It waits
up to 1.5 seconds for a fresh answer.
| Key | What it turns on and off |
| --- | --- |
| `capture_sessions` | Session start events |
| `capture_screens` | Screen events |
| `capture_lifecycle` | App opened and backgrounded events |
| `autocapture` | Taps and rage taps |
| `capture_rageclicks` | Rage taps only. Taps are still recorded |
Your settings in FounderHQ win. They can turn capture off for every install,
and on for an app that shipped with the wrong value. You cannot rebuild an app
people have already installed, so the dashboard decides. The options you pass
to the constructor apply until your settings arrive.
Events already queued under a key that turns off are dropped before they leave
the device.
Call `refreshRemoteConfig()` to fetch them again while the app runs. Call
`applyRemoteConfig(config)` to apply settings you supply yourself. Set
`remote_config: false` to skip the request.
## Next
Read the [protocol reference](/analytics/protocol-reference) for the wire
format, the reserved event names, and the campaign properties a deep link
can carry.
# Web (https://www.getfounderhq.com/docs/analytics/sdks/web)
Capture pageviews, clicks, and your own events from a browser, and tie
them to a contact.
You need a publishable key (`fhq_pk_...`) for the brand you want to
measure. See [Getting started](/analytics/getting-started).
## Install
With npm:
```npm
npm install @founderhq/events
```
Or with a script tag. The script defines a global named `founderhq`.
```html
```
## Initialize
Call `init` once, as early as you can. The SDK starts automatic capture
from that point.
```ts
import { founderhq } from "@founderhq/events";
founderhq.init("fhq_pk_XXXX", {
person_profiles: "identified_only",
});
```
With no remembered or explicit consent choice, the SDK starts pending. The
default mode records only cookieless pageviews and pageleaves until you call
`consent("granted")` after acceptance.
`founderhq` is a shared client. Call `createFounderHqClient()` instead when
you need a second, independent client.
## Capture events
```ts
founderhq.capture("trial.started", { plan: "growth" });
```
`track` is an alias for `capture`. Both queue the event and return at once.
The SDK sends it with the next batch after consent is granted; custom events
called while consent is pending or denied are ignored.
Awaiting `readyForCapture()` first tells you that stored state is loaded.
Use it when a test or a redirect must not race the queue.
Pageviews, pageleaves, sessions, semantic clicks, rage clicks, dead clicks,
outbound clicks, scroll depth, and Core Web Vitals are captured for you. Turn
each one off with its own option below.
Click capture stores semantic metadata only. It never stores visible text,
input values, page contents, or click coordinates. Add
`data-fhq-no-capture` or the class `fhq-no-capture` to keep a subtree out
of capture entirely.
Call `page()` to send a pageview yourself, and `screen(name, properties)`
for an app-like screen view.
### Name an event for people
An event name is an identifier: FounderHQ shows it exactly as you wrote it,
and never rewords it. Send `$display_name` when you want a label read
instead:
```ts
founderhq.capture("trial.started", {
$display_name: "Trial started",
plan: "growth",
});
```
The label is remembered against the event name, so sending it once is
enough, and a later event without it does not erase it. Send it again to
change it. Keep it short: it is a row label, not a sentence.
This works from any SDK, including the Node one, because it is an ordinary
event property.
## Identify a contact
```ts
founderhq.identify("user_42", { email: "jane@acme.com" });
```
Call this after consent is granted. `identify` never changes consent.
Everything captured after consent but before `identify` stays attached to the
same person.
Pass an account in the third argument to change identity and account
together:
```ts
founderhq.identify("user_42", { email: "jane@acme.com" }, {
account: { key: "workspace_123", properties: { plan: "growth" } },
});
```
Update contact properties on their own with `setPersonProperties(set,
setOnce)`. With the default `identified_only` mode, changes made before
`identify` stay on the device and go out with the identify call.
Repeating `identify` for the same ID is safe: the first call records the
identification, later calls only update properties, and a call with
nothing new sends nothing. `name` and `avatarUrl` fill the contact's name
and photo; see [Identity and contacts](/analytics/concepts/identity-and-contacts#properties-founderhq-understands).
Call `reset()` on logout. It clears the identity, the account, and the
session.
## Accounts
An account is the company or workspace a contact belongs to.
```ts
founderhq.setAccount("workspace_123");
founderhq.setAccountProperties({ plan: "growth", seats: 12 });
founderhq.clearAccount();
```
`setAccount` also takes an object with `key`, `properties`, and
`contextToken`. Pass `account` to `init` when the very first event must
already carry it. See
[Accounts and groups](/analytics/concepts/accounts-and-groups).
## Consent
| You call | What happens |
| --- | --- |
| `optIn()` | Capture is on, and the choice is remembered |
| `optOut()` | Capture stops, and queued data is cleared |
| `consent("granted")` | Same as `optIn()` |
| `consent("denied")` | The SDK stops writing identity and stores nothing |
| `isOptedOut()` | Returns `true` while capture is off |
Set `opt_out_by_default: true` to start silent until the visitor agrees.
Set `respect_dnt: true` to stay off when the browser sends Do Not Track.
Set `consent_default: "denied"` to start in the denied state.
If there is no remembered or explicit choice, the SDK starts pending and the
default `cookieless_mode: "when_not_granted"` records cookieless page traffic.
`getDistinctId()` and `getSessionId()` return `null` while consent is not
granted. See
[Consent and privacy](/analytics/concepts/consent-and-privacy).
## Checkout attribution
Attach these values to a Stripe or Dodo checkout so the payment keeps its
attribution.
```ts
const metadata = founderhq.checkoutMetadata();
const clientReferenceId = await founderhq.paymentLinkToken();
```
`checkoutMetadata()` returns `fhq_anonymous_id` and `fhq_session_id`, plus
`fhq_account_key` and `fhq_account_context` when an account is set.
`paymentLinkToken()` returns one opaque string for Stripe Payment Links and
Pricing Tables, or `null` when consent is not granted. See
[Checkout metadata and payment links](/analytics/revenue/checkout-metadata-and-payment-links).
## Options
Pass these in the second argument to `init`.
| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `mode` | `"events"` \| `"pageviews"` | `"events"` | `"pageviews"` sends pageviews only, and drops every other event |
| `host` | `string` | `https://i.getfounderhq.com` | Where events are sent |
| `flushAt` | `number` | `20` | Sends a batch once this many events are queued |
| `flushIntervalMs` | `number` | `5000` | Sends a batch on this timer |
| `maxQueueSize` | `number` | `100` | Largest queue held on the device |
| `storage` | adapter \| `false` | browser storage | Your own storage adapter, or `false` to keep the queue in memory |
| `person_profiles` | `"identified_only"` \| `"always"` \| `"never"` | `"identified_only"` | When contact property changes are applied |
| `opt_out_by_default` | `boolean` | `false` | Starts opted out, so nothing is captured until `optIn()` |
| `respect_dnt` | `boolean` | `false` | Stays off when the browser sends Do Not Track |
| `consent_default` | `"granted"` \| `"denied"` | pending | Overrides the consent state on a first visit |
| `cookieless_mode` | `"off"` \| `"always"` \| `"when_not_granted"` \| `"on_reject"` | `"when_not_granted"` | Chooses cookieless behavior before and after the consent choice |
| `autocapture` | `boolean` \| object | `true` | Click capture. The object takes `urlAllowlist`, `urlBlocklist`, `selectorAllowlist`, and `selectorBlocklist` |
| `capture_pageview` | `boolean` | `true` | Pageviews, including single-page navigations |
| `capture_pageleave` | `boolean` | `true` | Page exits, with time on page |
| `capture_sessions` | `boolean` | `true` | Session start events |
| `capture_rageclicks` | `boolean` | `true` | Repeated clicks on the same control |
| `capture_dead_clicks` | `boolean` | `true` | Clicks that change nothing |
| `capture_web_vitals` | `boolean` | `true` | One Core Web Vitals event per page |
| `capture_scroll` | `boolean` | `true` | Scroll depth on the page exit event |
| `capture_outbound_clicks` | `boolean` | `true` | Clicks on links that leave your domain |
| `cross_subdomain` | `boolean` | `true` | Keeps one visitor across `www.` and `app.` on your domain |
| `cookie_domain` | `string` | discovered | Overrides the cookie domain the SDK finds |
| `remote_config` | `boolean` | `true` | Reads capture settings you set in FounderHQ |
| `beforeSend` | `(event) => event \| null` | none | Changes each event, or returns `null` to drop it |
| `account` | `string` \| object \| `null` | none | Installs account context before the first event |
| `redactUrlParams` | `true` \| `string[]` | see below | Replaces the listed query values with `[redacted]` |
| `captureContext` | `boolean` | `true` | Set `false` to stop sending browser, OS, screen, and page context |
| `tracingHeaders` | `boolean` \| `string[]` | `false` | Adds the session id to your own API calls, so backend events join the visit. `true` covers same-origin requests; pass origins for a separate API host |
| `context` | `object` | none | Your own values, added to every event |
`redactUrlParams` redacts `token`, `access_token`, `auth`, `code`,
`password`, `secret`, and `key` even when you leave it out. Pass an array
to replace that list with your own.
`tracingHeaders` wraps `fetch` and `XMLHttpRequest` so every request to
your own origin carries `x-founderhq-session-id`. Your backend reads that
header and passes it to the Node SDK, and the events it sends then sit
inside the visit that caused them. See
[Join backend events to a visit](/analytics/recipes/session-stitching).
## Methods
| Method | Returns | What it does |
| --- | --- | --- |
| `init(key, options)` | the client | Starts the SDK. Later calls do nothing |
| `capture(event, properties)` | — | Queues one event |
| `track(event, properties)` | — | Alias for `capture` |
| `readyForCapture()` | `Promise` | Resolves once stored state is loaded |
| `identify(distinctId, properties, options)` | — | Ties the visitor to a contact |
| `setAccount(account)` | — | Sets the active account |
| `clearAccount()` | — | Removes the active account |
| `setAccountProperties(properties)` | — | Updates the active account |
| `setPersonProperties(set, setOnce)` | — | Updates contact properties |
| `screen(name, properties)` | — | Records a screen view |
| `page(properties)` | — | Records a pageview |
| `register(properties)` | — | Adds properties to every later event |
| `registerOnce(properties)` | — | Same, but keeps an existing value |
| `unregister(key)` | — | Removes one registered property |
| `optIn()` / `optOut()` | — | Turns capture on or off |
| `consent(state)` | — | Sets `"granted"` or `"denied"` |
| `isOptedOut()` | `boolean` | Tells you whether capture is off |
| `getDistinctId()` | `string \| null` | The current identity |
| `getSessionId()` | `string \| null` | The current session |
| `checkoutMetadata()` | object | Attribution fields for a checkout |
| `paymentLinkToken()` | `Promise` | A reference for Stripe Payment Links |
| `reset()` | — | Clears identity, account, and session |
| `flush()` | `Promise` | Sends queued events now |
| `close()` | `Promise` | Sends the page exit event, flushes, and stops |
| `applyRemoteConfig(config)` | `Promise` | Applies capture settings you supply yourself |
## Remote config
The SDK reads your capture settings from FounderHQ at startup, so you can
turn capture on or off without a redeploy. It caches the last answer in the
browser and uses it on the next load.
On a first visit the SDK waits up to 1.5 seconds for those settings before
it arms automatic capture. A slow answer never costs you the landing
pageview. Settings that arrive later re-arm capture, and remove queued
events they disable.
Set `remote_config: false` to skip the request.
## Next
Read the [protocol reference](/analytics/protocol-reference) for the wire
format, the reserved event names, and the campaign properties the SDK
reads from a URL.
# Add FounderHQ to Bolt (https://www.getfounderhq.com/docs/analytics/recipes/bolt)
Paste one prompt into Bolt and get working analytics on every page. About
3 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once.
## 1. Paste this prompt into Bolt
Replace `fhq_pk_XXXX` with your own key first. The key is publishable, so
it is safe in the browser and safe in your repository.
Prompt for Bolt:
```text
Add FounderHQ analytics to this project.
1. Detect the project shape first, then pick one placement:
- If there is an index.html, add the two script tags to its .
- If the project is Next.js or renders a root layout component, install
@founderhq/events instead, and call founderhq.init("fhq_pk_XXXX")
from one client component rendered in the root layout.
Pick one placement only. Never install it twice.
2. For the index.html placement, the tags are exactly:
3. Use the key fhq_pk_XXXX exactly as written. It is a publishable key.
Do not move it to a server secret.
4. Only if this app already has sign-in: after a successful sign-in, call
founderhq.identify with the user's stable database id, and their email
as a property. Call founderhq.reset() on sign-out. If the app has no
sign-in, skip this step. Do not add authentication.
5. Do not add any other analytics tool. Do not change analytics tools that
are already installed.
End state: every page loads the SDK once, founderhq.init runs once with my
key, and pageviews are captured without any further code. Tell me which
placement you chose and which file you edited.
```
## 2. What the prompt does
- It makes the agent look at the project before it writes code. Bolt
starts Vite projects and Next.js projects, and each needs a different
placement.
- It gives the agent the exact snippet, so the agent invents nothing.
- It asks the agent to report the file it edited, so you can check the
work in one click.
- It adds `identify` only when your app already knows who the person is.
After `init` runs, FounderHQ captures pageviews, page leaves, sessions,
clicks, rage clicks, dead clicks, outbound clicks, scroll depth, and Core
Web Vitals for you.
## 3. Do it by hand instead
1. Open `index.html`.
2. Paste the two script tags into the ``.
```html
```
3. Deploy the project.
Next.js App Router? Use [Add FounderHQ to Next.js](/analytics/recipes/nextjs)
instead.
## 4. Let your agent read these docs
Connect the [docs MCP server](/analytics/ai-resources/mcp-server), then name it
in your prompt. Your agent then reads these pages while it writes the code.
## Verify
1. Open the deployed site and load a page.
2. In FounderHQ, open **Sequences → Events**.
3. You see a `$pageview` for that page, with its channel.
4. Signed in during the test? Open **Sequences → Contacts** and find the
person.
Events can take a few seconds to appear. The SDK batches them.
## Troubleshoot
### Events work in the Bolt preview but not on the deployed site
The deployed domain is not on the key. Add it to **Allowed origins** in
**Sequences → Settings → Event API keys**.
### Pageviews arrive twice
The agent added the snippet to `index.html` and to a component. Keep one.
# Dodo end to end (https://www.getfounderhq.com/docs/analytics/recipes/dodo-end-to-end)
Go from no Dodo connection to a payment that shows the channel that
brought the buyer. About 15 minutes, once.
You need a FounderHQ brand with the [web SDK](/analytics/sdks/web) installed on
your pricing page, and a Dodo Payments account you can open the dashboard
for. New to the SDK? Start with
[Quickstart: Web (snippet)](/analytics/getting-started/quickstart-web-snippet).
Revenue attribution needs two halves, and one half alone tells you nothing:
1. **The Dodo connection** tells FounderHQ that money moved.
2. **The checkout metadata** tells FounderHQ who to thank.
Do them in that order. A read-only API key is the recommended connection: no
webhook setup, and revenue appears within a few minutes.
## 1. Connect Dodo
Follow [Connect Dodo](/analytics/revenue/connect-dodo). In short, you:
- Create a live API key in Dodo and leave **Enable write access** off.
- Paste that key into the Dodo connection for your FounderHQ brand.
- Wait a few minutes for the first sync, or choose **Sync now**.
FounderHQ verifies the key against Dodo's live API and binds its business
before saving it. The key is encrypted and can be revoked from Dodo anytime.
Prefer webhooks? The same connection page keeps the manual webhook path. Add
the URL FounderHQ gives you, select the listed events, paste Dodo's signing
secret, and send a signed test event. Both paths feed the same revenue records.
## 2. Attach attribution when you create the checkout
Dodo carries your metadata from the checkout into the payment. The browser
holds the two IDs, and your server builds the metadata object.
Read the IDs in the browser, and post them with the rest of the checkout
request:
```ts
import { founderhq } from "@founderhq/events";
const identity = founderhq.checkoutMetadata();
// { fhq_anonymous_id, fhq_session_id }
```
On your server, build the metadata with the Node SDK and pass it as the
`metadata` object of your Dodo payment or subscription call. The server
code is on [Connect Dodo](/analytics/revenue/connect-dodo), step 5.
`checkoutMetadata()` in the browser returns an empty object when the
visitor has not granted consent. Treat that as normal, and complete the
sale without attribution.
## 3. Know what creates revenue
| Dodo event | Effect in FounderHQ |
| --- | --- |
| `payment.succeeded` | Creates revenue. This covers first payments, renewals, and Indian recurring debits. |
| `refund.succeeded` | Subtracts revenue. |
| `subscription.*` | Updates subscription state and MRR only. It never creates revenue. |
| `dispute.*` | Stored as evidence. It does not change revenue today. |
Dodo fires a renewal event alongside the first payment. That is why
lifecycle events never create money on their own.
Renewals carry no browser session. FounderHQ binds the Dodo customer and
subscription on the first payment, and resolves renewals through that
binding.
Selling in India? FounderHQ stores both the charged total and the
settlement amount. Channel revenue uses the charged total.
## Verify
1. In FounderHQ, open Revenue. The Dodo connection shows **Connected**.
2. Start a checkout on your pricing page, and take a live payment.
3. In Dodo, open the payment. Its metadata shows `fhq_anonymous_id` and
`fhq_session_id`.
4. In FounderHQ, open Revenue. The payment shows the channel that brought
the buyer, under **Introduced by** and **Closed by**.
## Troubleshoot
### The payment arrives, but with no channel
The webhook half works and the metadata half does not. Open the payment in
Dodo and look for the two IDs. Empty IDs mean the visitor denied consent,
or the buyer never loaded a page that runs the SDK.
### FounderHQ asks for a live-mode API key
The pasted key belongs to Dodo test mode. Create the key from your live Dodo
dashboard and leave write access off.
### The webhook connection stays on "Waiting for test"
No signed event has arrived yet. Send a test event from the Dodo endpoint.
### Dodo shows "Invalid signature"
The pasted secret is wrong, or you rotated it more than 24 hours ago. Copy
the current secret from Dodo and paste it again.
# Add FounderHQ to Ghost (https://www.getfounderhq.com/docs/analytics/recipes/ghost)
Add FounderHQ to every page of your Ghost site, without touching the
theme. About 2 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once. You also
need admin access to your Ghost site.
## 1. Open Code injection in the Ghost admin
In the Ghost admin, open **Code injection**. It holds two fields: one is
added to the head of every page, the other to the foot.
## 2. Paste the snippet into the site header field
Use the header field, the one that applies to the whole site. Replace
`fhq_pk_XXXX` with your key.
```html
```
Save the change. Ghost applies it to every page at once: the home page,
every post, every tag page, and every static page.
Do not paste it into a single post's code injection. That measures one
post only.
## 3. Skip identify on a blog
A blog has no sign-in, so there is nobody to identify. FounderHQ still
ties each visitor's pageviews to one anonymous contact, and links that
history to a person later, when they sign up in your product.
Run paid memberships, and does your theme know the signed-in member's id?
Then you can call `founderhq.identify` with that id. Otherwise leave it
out.
## 4. Capture your own events, if you want them
Add a click handler in the same code injection field for a fact you care
about.
```html
```
Use lowercase names you will still understand in six months. Event names
that start with `$` are reserved.
## Verify
1. Open your site in a private window and read a post.
2. In FounderHQ, open **Sequences → Events**.
3. You see a `$pageview` for that post, with the channel that brought the
visitor.
4. Open a second post. A second `$pageview` arrives.
Events can take a few seconds to appear. The SDK batches them.
## Troubleshoot
### Nothing appears in Events
Open your browser console on the live site. If you see no request to
`/i/v2/e`, the snippet is not on the page. Confirm you saved the site-wide
header field, not a single post's field.
### Requests to `/i/v2/e` return 401 or 403
The key is wrong, or your Ghost domain is not allowed. Check **Allowed
origins** on the key in **Sequences → Settings → Event API keys**.
### Your pageviews look low
FounderHQ keeps crawler visits out of your numbers, and search engines
crawl a blog hard. See
[Bot traffic policy](/analytics/concepts/bot-traffic-policy).
# Recipes (https://www.getfounderhq.com/docs/analytics/recipes)
Recipes are walkthroughs for the platform you actually build on. Each one
takes a real setup — a framework, a hosting platform, an AI builder — and
shows the shortest path to working FounderHQ analytics on it, ending with
what you click to confirm it works. The list below is growing; if your
platform is not here yet, start with
[Getting started](/analytics/getting-started), which covers the same steps in
general form.
# Add FounderHQ to Lovable (https://www.getfounderhq.com/docs/analytics/recipes/lovable)
Paste one prompt into Lovable and get working analytics on every page.
About 3 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once.
## 1. Paste this prompt into Lovable
Replace `fhq_pk_XXXX` with your own key first. The key is publishable, so
it is safe in the browser and safe in your repository.
Prompt for Lovable:
```text
Add FounderHQ analytics to this project.
1. Look at how this project renders HTML, then pick one placement:
- If the project has an index.html, add the two script tags to its .
- If the project renders a root layout or a top-level App component
instead, add the same tags there, so they load once on every page.
Pick one placement only. Never add the tags twice.
2. The tags are exactly:
3. Use the key fhq_pk_XXXX exactly as written. It is a publishable key.
Do not move it to a secret or an environment variable.
4. Only if this app already has sign-in: after a successful sign-in, call
founderhq.identify with the user's stable database id, and their email
as a property. Call founderhq.reset() on sign-out. If the app has no
sign-in, skip this step. Do not add authentication.
5. Do not add any other analytics tool. Do not change analytics tools that
are already installed.
End state: every page loads events.js once, founderhq.init runs once with
my key, and pageviews are captured without any further code.
```
## 2. What the prompt does
- It tells the agent to find the one file that renders every page.
- It gives the agent the exact snippet, so the agent invents nothing.
- It blocks the two common mistakes: the snippet added twice, and the
publishable key hidden in a secret it cannot read in the browser.
- It adds `identify` only when your app already knows who the person is.
After `init` runs, FounderHQ captures pageviews, page leaves, sessions,
clicks, rage clicks, dead clicks, outbound clicks, scroll depth, and Core
Web Vitals for you.
## 3. Do it by hand instead
1. Open the file that renders every page. In most Lovable projects that is
`index.html`.
2. Paste the two script tags into the ``.
```html
```
3. Publish or preview the project.
Building a Next.js app instead? Use
[Add FounderHQ to Next.js](/analytics/recipes/nextjs).
## 4. Let your agent read these docs
Connect the [docs MCP server](/analytics/ai-resources/mcp-server), then name it
in your prompt. Your agent then reads these pages while it writes the code.
## Verify
1. Open your published Lovable app and load a page.
2. In FounderHQ, open **Sequences → Events**.
3. You see a `$pageview` for that page, with its channel.
4. Signed in during the test? Open **Sequences → Contacts** and find the
person.
Events can take a few seconds to appear. The SDK batches them.
## Troubleshoot
### Nothing appears in Events
Open your browser console. If requests to `/i/v2/e` fail with a 401 or
403, the key is wrong, or your published domain is not allowed. Check
**Allowed origins** on the key in **Sequences → Settings → Event API
keys**. Add both the preview domain and the live domain.
### Pageviews arrive twice
The agent added the snippet in two places. Keep the one in the file that
renders every page, and delete the other.
# Add FounderHQ to Next.js (https://www.getfounderhq.com/docs/analytics/recipes/nextjs)
Measure your Next.js app end to end: pageviews from the browser, and
facts only your server knows. About 10 minutes.
You need a FounderHQ account and two keys. In the app, open
**Sequences → Settings → Event API keys**. Create one key for
**Browser/mobile** (`fhq_pk_...`) and one for **Backend**
(`fhq_sk_...`). Each full key is shown only once. Keep the secret key on
your server.
This page covers the App Router. Pages Router? Call `founderhq.init` once
in `pages/_app.tsx` instead, and the rest of the page still applies.
## 1. Install the browser package
```npm
npm install @founderhq/events
```
## 2. Initialize in a client component
The App Router renders on the server, so `init` belongs in a client
component that you render once in your root layout.
```tsx
"use client";
import { useEffect } from "react";
import { founderhq } from "@founderhq/events";
export function FounderHqAnalytics() {
useEffect(() => {
founderhq.init("fhq_pk_XXXX");
}, []);
return null;
}
```
`founderhq` is a shared client. Import it anywhere. Calling `init` a
second time does nothing, so a hot reload is safe.
## 3. Render it once in the root layout
```tsx
// app/layout.tsx
import { FounderHqAnalytics } from "./founderhq-analytics";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
```
From here on, pageviews, page leaves, sessions, clicks, rage clicks, dead
clicks, outbound clicks, scroll depth, and Core Web Vitals are captured
for you. App Router navigations count as pageviews.
## 4. Identify the person when they sign in
```ts
founderhq.identify("user_8421", {
email: "jane@acme.com",
plan: "pro",
});
```
Pass your own stable user ID, not an email. Call `founderhq.reset()` when
the person signs out.
## 5. Send server-truth events from route handlers
Your server knows facts the browser cannot prove: a validated signup, a
plan change, a webhook you received. Send those with the Node SDK.
```npm
npm install @founderhq/events-node
```
```ts
// lib/analytics.ts
import { FounderHqNode } from "@founderhq/events-node";
export const events = new FounderHqNode(process.env.FOUNDERHQ_SECRET_KEY!);
```
Put `FOUNDERHQ_SECRET_KEY=fhq_sk_XXXX` in your server environment, with no
`NEXT_PUBLIC_` prefix. That prefix would ship the secret to the browser.
```ts
// app/api/upgrade/route.ts
import { events } from "@/lib/analytics";
export async function POST() {
events.capture({
contact: { externalId: "user_8421", email: "jane@acme.com" },
event: "subscription.upgraded",
properties: { plan: "growth", mrr: 99 },
});
await events.flush();
return Response.json({ ok: true });
}
```
Use the same `externalId` you pass to `identify` in the browser. That is
what keeps one contact instead of two.
Serverless functions exit fast, so `await events.flush()` before you
return. Otherwise the batch dies with the function.
## 6. Attribute your revenue
Taking payments with Stripe? Follow
[Stripe end to end](/analytics/recipes/stripe-end-to-end) to connect the
webhook and carry attribution through checkout.
## Verify
1. Run the app and load two pages.
2. In FounderHQ, open **Sequences → Events**. You see two `$pageview`
events, each with its channel.
3. Call the route handler once.
4. Open **Sequences → Contacts** and open Jane. You see
`subscription.upgraded` with `plan: growth` on her timeline.
Events can take a few seconds to appear. The SDK batches them.
## Troubleshoot
### `founderhq is not defined` during a build
`init` ran on the server. Keep it inside the client component with
`"use client"` at the top.
### Events fire twice in development
React Strict Mode runs effects twice. `init` is safe to call twice, but a
`capture` inside an effect is not. Capture on the user's action, not on
render.
### A second contact appeared for the same person
Your browser code and your server code use different IDs. Both must use
the same stable user ID. See
[Identity and contacts](/analytics/concepts/identity-and-contacts).
## Next
- [Web SDK options](/analytics/sdks/web)
- [Node SDK reference](/analytics/sdks/node)
# Add FounderHQ to Replit (https://www.getfounderhq.com/docs/analytics/recipes/replit)
Paste one prompt into the Replit agent and get working analytics on every
page. About 5 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once.
## 1. Store the key in your project
Open your project's environment or secrets settings in Replit. Add the key
there, so it is not typed into several files.
```text
FOUNDERHQ_PUBLISHABLE_KEY = fhq_pk_XXXX
```
The build tool decides the name the browser can read. Vite needs a
`VITE_` prefix. Next.js needs a `NEXT_PUBLIC_` prefix. The prompt below
tells the agent to rename the variable for you.
This key is publishable. A visitor can read it in your page source, and
that is expected. Never put a secret key (`fhq_sk_...`) in a browser.
## 2. Paste this prompt into Replit
Prompt for Replit:
```text
Add FounderHQ analytics to this project.
1. Read FOUNDERHQ_PUBLISHABLE_KEY from my project environment. If my build
tool needs a prefix for the browser to read a variable, rename it to
that convention, tell me the new name, and use the new name in code. Do
not paste the key value into any file.
2. Look at how this project renders HTML, then pick one placement:
- If the project has an index.html, add the FounderHQ script tags to
its .
- If the project renders a root layout or a top-level App component
instead, add the SDK there, so it loads once on every page.
Pick one placement only. Never install it twice.
3. For an index.html placement, use exactly:
For a component placement, install @founderhq/events and call
founderhq.init(THE_KEY) once at the app's entry point.
4. Only if this app already has sign-in: after a successful sign-in, call
founderhq.identify with the user's stable database id, and their email
as a property. Call founderhq.reset() on sign-out. If the app has no
sign-in, skip this step. Do not add authentication.
5. Do not add any other analytics tool. Do not change analytics tools that
are already installed.
End state: every page loads the SDK once, founderhq.init runs once with
the key from the environment, and pageviews are captured without any
further code.
```
## 3. What the prompt does
- It keeps the key in one place, and out of your source files.
- It makes the agent rename the variable to whatever your build tool
needs, and report the new name back to you.
- It tells the agent to find the one file that renders every page, so the
SDK loads once.
- It adds `identify` only when your app already knows who the person is.
After `init` runs, FounderHQ captures pageviews, page leaves, sessions,
clicks, rage clicks, dead clicks, outbound clicks, scroll depth, and Core
Web Vitals for you.
## 4. Do it by hand instead
1. Add the key to your project's environment settings, with the prefix
your build tool needs.
2. Paste the snippet into the `` of `index.html`.
```html
```
3. Restart the project so the new environment value is read.
No `index.html`? Follow [Quickstart: Web (npm)](/analytics/getting-started/quickstart-web-npm),
or [Add FounderHQ to Next.js](/analytics/recipes/nextjs) for the App Router.
## 5. Let your agent read these docs
Connect the [docs MCP server](/analytics/ai-resources/mcp-server), then name it
in your prompt. Your agent then reads these pages while it writes the code.
## Verify
1. Open your Replit app on its published URL and load a page.
2. In FounderHQ, open **Sequences → Events**.
3. You see a `$pageview` for that page, with its channel.
4. Signed in during the test? Open **Sequences → Contacts** and find the
person.
Events can take a few seconds to appear. The SDK batches them.
## Troubleshoot
### The page logs an error about an undefined key
The browser cannot read that variable name. Rename it with the prefix your
build tool requires, then restart the project so the build picks it up.
### Nothing appears in Events
Check the network tab. If requests to `/i/v2/e` fail with a 401 or 403,
the key is wrong, or the domain is not allowed. Add both your development
URL and your published URL to **Allowed origins** on the key, in
**Sequences → Settings → Event API keys**.
# Send events through your own domain (https://www.getfounderhq.com/docs/analytics/recipes/reverse-proxy)
Serve FounderHQ analytics from a path on your own domain instead of
`i.getfounderhq.com`. About 15 minutes, and you need control of your
site's routing or web server.
You need FounderHQ analytics already working. See
[Getting started](/analytics/getting-started). This recipe changes where
the browser sends events, not what it sends.
## Why you would do this
Content blockers, some browser extensions, and a few corporate networks
block requests to known analytics hosts by name. Those visitors load your
page, but their events never leave the browser. You see a smaller number
than reality, and the gap grows with a technical audience.
A reverse proxy fixes it at the root. The browser calls a path on your own
domain. Your server forwards that call to FounderHQ. There is no
third-party host in the request the browser makes, so a host-based blocklist
has nothing to match.
This is a first-party path, not a disguise. Keep saying what you collect in
your privacy policy, and keep honoring consent the same way.
## How the SDK builds the URL
The `host` option is a base. The SDK appends the path itself:
| Call | Path the SDK appends |
| --- | --- |
| Send a batch of events | `/i/v2/e` |
| Fetch remote config | `/i/v1/analytics/config` |
| Mint a revenue attribution token | `/i/v2/revenue-token` |
So `host: "https://your-domain.com/fhq"` makes the browser post to
`https://your-domain.com/fhq/i/v2/e`. Your rewrite strips `/fhq` and
forwards `/i/v2/e` to `https://i.getfounderhq.com`. One rule covers all
three calls.
The gzip path adds `?compression=gzip-js` to the URL. Your proxy must pass
the query string through. Every example below does.
## Next.js
Add a rewrite in `next.config.ts`:
```ts title="next.config.ts"
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/fhq/:path*",
destination: "https://i.getfounderhq.com/:path*",
},
];
},
};
export default nextConfig;
```
Then point the SDK at the path:
```ts
import { founderhq } from "@founderhq/events";
founderhq.init("fhq_pk_XXXX", {
host: "https://your-domain.com/fhq",
});
```
The SDK only concatenates strings, so a same-origin `host: "/fhq"` also
works and needs no per-environment value.
## Vercel
If you deploy a static site or a non-Next framework on Vercel, put the same
rule in `vercel.json`:
```json title="vercel.json"
{
"rewrites": [
{
"source": "/fhq/:path*",
"destination": "https://i.getfounderhq.com/:path*"
}
]
}
```
A Next.js app on Vercel should use `next.config.ts` above instead. Do not
write both.
## nginx
```nginx
location /fhq/ {
proxy_pass https://i.getfounderhq.com/;
proxy_set_header Host i.getfounderhq.com;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_buffering off;
}
```
The trailing slash on both `location` and `proxy_pass` is what strips
`/fhq`. Without it nginx forwards `/fhq/i/v2/e` and every request returns
`404`.
`proxy_ssl_server_name on` sends SNI to the upstream. Leave it out and the
TLS handshake fails.
## Cloudflare Worker
Route the Worker at `your-domain.com/fhq/*`:
```js
const INGEST_ORIGIN = "https://i.getfounderhq.com";
export default {
async fetch(request) {
const url = new URL(request.url);
if (!url.pathname.startsWith("/fhq/")) {
return new Response("Not found", { status: 404 });
}
const target = new URL(
url.pathname.slice("/fhq".length) + url.search,
INGEST_ORIGIN,
);
return fetch(new Request(target, request));
},
};
```
`new Request(target, request)` keeps the method, headers, and body. The
Worker runtime sets the upstream `Host` header from the URL.
## Host the snippet through the proxy too
The hosted snippet is one more path behind the same rule. Load it from your
domain and a blocklist never sees the script request either:
```html
```
## What does not change
**Allowed origins still name your own site.** A rewrite is server-side, so
the browser's request stays on your domain and its `Origin` header names
your site. If your publishable key carries an allowed-origin list, put the
origins your pages are actually served from on it:
```
https://your-domain.com
https://www.your-domain.com
```
An origin is scheme, host, and port. It has no path — add
`https://your-domain.com`, never `https://your-domain.com/fhq`. Add
`http://localhost:3000` while you develop.
**Identity is unaffected.** The SDK reads and writes its cookies in the
browser, on your own domain, and puts the IDs it needs into the event
body. They were already first-party. Proxying does not add or remove a
cookie.
**Remote config and revenue tokens follow the same base.** Once `host`
points at `/fhq`, all three calls go through the proxy. Do not cache
responses under that path: remote config is per key and the revenue token
is per visitor.
**Your server SDKs need no proxy.** `@founderhq/events-node` and crawler
tracking run on your server, where no content blocker exists. Leave their
`host` and `endpoint` at the default.
**Journey embeds do not use this path.** The web Journeys SDK accepts a
`baseUrl`, but honors only a localhost value and falls back to
`https://app.getfounderhq.com` for anything else. A Journey on your site
always calls FounderHQ directly.
## Verify
1. Open your site with the browser network panel filtered to `fhq`.
2. Click something. A `POST` to `/fhq/i/v2/e` on your own domain returns
`202`.
3. Open Analytics in FounderHQ. The event is in the live feed.
4. Turn on a content blocker and repeat step 2. The request still returns
`202`.
## Troubleshoot
### Every proxied request returns 404
The prefix is not being stripped. In nginx, both `location /fhq/` and
`proxy_pass https://i.getfounderhq.com/` need their trailing slash. In a
Worker, check that you slice `/fhq` off the pathname before building the
target URL.
### Requests return 401 or 403 through the proxy but work directly
Your proxy is dropping the `Authorization` header. nginx keeps it by
default; a framework middleware or a WAF rule in front of it may not.
### Gzipped batches fail but small ones succeed
The query string is being dropped. FounderHQ reads `?compression=gzip-js`
to know the body is compressed. Forward the full query string.
# Join backend events to a visit (https://www.getfounderhq.com/docs/analytics/recipes/session-stitching)
Your server never sees the browser, so an event it sends has no visit
attached. In a person's activity it sits on its own, away from the pages
that led to it. Pass the session id along with the request and the two
join up. About 10 minutes.
You need the web SDK running on your site and the Node SDK on your server.
See [Getting started](/analytics/getting-started).
## What this fixes
A checkout is one story told by two systems. The browser records the pages
and the click; your server records the charge. Without the session id, the
charge is filed as unrelated activity and the visit looks abandoned.
## 1. Send the header from the browser
Turn on `tracingHeaders`. The SDK then adds `x-founderhq-session-id` to
your own API calls:
```ts
founderhq.init("fhq_pk_...", { tracingHeaders: true });
```
That covers requests to the page's own origin. If your API is on another
host, list it, and allow the header in that host's CORS policy:
```ts
founderhq.init("fhq_pk_...", {
tracingHeaders: ["https://api.example.com"],
});
```
Prefer to do it by hand? Read the value and set the header yourself:
```ts
const sessionId = founderhq.getSessionId();
await fetch("/api/checkout", {
method: "POST",
headers: sessionId ? { "x-founderhq-session-id": sessionId } : {},
});
```
## 2. Read it on the server
```ts
events.capture({
contact: { externalId: user.id },
event: "subscription.upgraded",
properties: { plan: "growth" },
sessionId: request.headers.get("x-founderhq-session-id"),
});
```
A missing or malformed value is dropped, and the event still lands. Session
stitching is a nicety; it never costs you the fact itself.
## When to leave it out
Omit `sessionId` for work no visit caused: a nightly job, a queue worker, a
webhook from your payment provider. Those events belong outside a visit,
and forcing them into one would misreport it.
A late arrival is safe. A job that finishes an hour after the person left
joins their visit without stretching how long the visit lasted, because a
visit's shape is read from what the browser saw.
## Two rules worth keeping
Send the session id only. Never send a user id in a header and trust it:
anyone can forge a header, and your server already knows who the caller is.
Treat the session id as a grouping key, not a secret. It says which visit
an event belongs to, and nothing more.
## Next
- [Node SDK](/analytics/sdks/node)
- [Web SDK](/analytics/sdks/web)
# Stripe end to end (https://www.getfounderhq.com/docs/analytics/recipes/stripe-end-to-end)
Go from no Stripe connection to a payment that shows the channel that
brought the buyer. About 20 minutes, once.
You need a FounderHQ brand with the [web SDK](/analytics/sdks/web) installed
on your pricing page, and a Stripe account you can open the dashboard for. New
to the SDK? Start with [Quickstart: Web
(snippet)](/analytics/getting-started/quickstart-web-snippet).
Revenue attribution needs two halves, and one half alone tells you nothing:
1. **The Stripe connection** tells FounderHQ that you were paid.
2. **The checkout metadata** tells FounderHQ who to thank.
Do them in that order.
## 1. Connect Stripe
Follow [Connect Stripe](/analytics/revenue/connect-stripe). In short, you:
- Open **Settings → Integrations → Revenue** and choose your brand.
- Choose Stripe for the brand.
- Leave **Connect with Stripe** selected and approve access on Stripe's page.
Your revenue appears in seconds.
Prefer not to authorize FounderHQ? Paste a read-only key instead. Your revenue
appears within a few minutes, and the first connection can see about the last
30 days. The manual webhook option remains available on the same screen.
Keep the environments matched. Test and live revenue use separate
connections.
## 2. Pick the path that matches your checkout
Now carry the visitor's IDs into the payment. How you do that depends on
how you charge. Pick one row, and follow that page.
| Which Stripe setup do you have? | You will | Page |
| ------------------------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Buyers click a Stripe-hosted Payment Link or a Pricing Table | Put a minted token in `client_reference_id` | [Stripe Payment Links](/analytics/revenue/connect-stripe/payment-links) |
| Your server creates Checkout Sessions | Put metadata on the session, and on the subscription or PaymentIntent | [Stripe Checkout API](/analytics/revenue/connect-stripe/checkout-api) |
| You built your own payment form on PaymentIntents | Put metadata on the PaymentIntent | [Stripe PaymentIntent API](/analytics/revenue/connect-stripe/payment-intent-api) |
Not sure which one you have? Look at what the buyer sees. A checkout page
on `stripe.com` or `buy.stripe.com` is the first row. Your own card form
is the third row. Anything else your server creates is the second row.
Two paths in one product, for example a Payment Link for a lifetime deal
and Checkout Sessions for plans? Follow both pages. They do not conflict.
## 3. Keep renewals attributed
A Checkout Session describes one purchase. Its metadata does not travel to
next year's renewal.
Put the same metadata on the object that renews as well: the subscription
for subscriptions, the PaymentIntent for one-time payments. FounderHQ also
binds the Stripe customer and subscription on the first payment, so a
renewal with no metadata still resolves to the same contact.
FounderHQ works out a renewal's channel from the contact's touches at the
time of the renewal, then freezes it. Nothing later rewrites a payment.
See [checkout metadata and payment links](/analytics/revenue/checkout-metadata-and-payment-links).
## Verify
1. In FounderHQ, open **Settings → Integrations → Revenue**. The Stripe
connection shows **Connected**.
2. Open your pricing page in a browser, and start a checkout.
3. In Stripe, open the object you created. It shows `fhq_anonymous_id` and
`fhq_session_id`, or the link URL carries `client_reference_id`.
4. Pay with a Stripe test card, on a test connection.
5. In FounderHQ, open Revenue. The payment shows the channel that brought
the buyer, under **Introduced by** and **Closed by**.
If you chose the manual webhook, Stripe shows a `200` response for the
delivery. That is the signal FounderHQ accepted the event.
## Troubleshoot
### The payment arrives, but with no channel
The Stripe connection works and the metadata half does not. Open the Stripe
object and look for the two IDs. Empty IDs mean the visitor denied consent, or
the buyer never loaded a page that runs the SDK.
### The payment does not arrive at all
Open **Settings → Integrations → Revenue**, then open the Stripe connection.
Reconnect Stripe if access was revoked, choose **Sync now** for a read-only
key, or inspect the delivery response for a manual webhook. The messages and
what each one means are on [Connect Stripe](/analytics/revenue/connect-stripe).
### Payments do not show up
FounderHQ records live payments. Make sure your key or webhook endpoint is a
live one — Stripe test-mode activity is not tracked.
# Add FounderHQ to v0 (https://www.getfounderhq.com/docs/analytics/recipes/v0)
Paste one prompt into v0 and get working analytics on every page. About
5 minutes.
You need a FounderHQ account and a publishable key. In the app, open
**Sequences → Settings → Event API keys**, create a key for
**Browser/mobile**, and copy it. The full key is shown only once.
v0 builds React and Next.js projects, so this recipe uses the npm package
rather than a script tag.
## 1. Store the key as an environment variable
Open your project's environment settings in v0, or the environment
variables of the Vercel project it deploys to. Add:
```text
NEXT_PUBLIC_FOUNDERHQ_KEY = fhq_pk_XXXX
```
The `NEXT_PUBLIC_` prefix is what lets the browser read the value. This
key is publishable, so a visitor seeing it is expected. Never put a secret
key (`fhq_sk_...`) in a browser.
Add the value to every environment you preview or ship from.
## 2. Paste this prompt into v0
Prompt for v0:
```text
Add FounderHQ analytics to this project.
1. Install the package @founderhq/events.
2. Read the key from process.env.NEXT_PUBLIC_FOUNDERHQ_KEY. Do not paste
the key value into any file. If the project is not Next.js, rename the
variable to the prefix my build tool needs and tell me the new name.
3. Detect the project shape, then pick one placement:
- Next.js App Router: create a client component that calls
founderhq.init(key) inside a useEffect with an empty dependency array
and returns null. Render that component once in app/layout.tsx.
- A plain React app: call founderhq.init(key) once at the app's entry
point, at module scope.
Pick one placement only. Never initialize it twice.
4. Only if this app already has sign-in: after a successful sign-in, call
founderhq.identify with the user's stable database id, and their email
as a property. Call founderhq.reset() on sign-out. If the app has no
sign-in, skip this step. Do not add authentication.
5. Do not add any other analytics tool. Do not change analytics tools that
are already installed.
End state: the app renders one FounderHQ initializer, founderhq.init runs
once with the key from the environment, and pageviews are captured without
any further code.
```
## 3. What the prompt does
- It keeps the key in the environment, and out of your source files.
- It makes the agent check whether the project is App Router or plain
React, because `init` runs in the browser only.
- It pins the App Router answer to a client component, which is the same
pattern as [Quickstart: Web (npm)](/analytics/getting-started/quickstart-web-npm).
- It adds `identify` only when your app already knows who the person is.
After `init` runs, FounderHQ captures pageviews, page leaves, sessions,
clicks, rage clicks, dead clicks, outbound clicks, scroll depth, and Core
Web Vitals for you. Single-page navigations count as pageviews.
## 4. Do it by hand instead
1. Install the package.
```npm
npm install @founderhq/events
```
2. Add the client component.
```tsx
"use client";
import { useEffect } from "react";
import { founderhq } from "@founderhq/events";
export function FounderHqAnalytics() {
useEffect(() => {
founderhq.init(process.env.NEXT_PUBLIC_FOUNDERHQ_KEY!);
}, []);
return null;
}
```
3. Render `` once in your root layout.
Want the server side as well? See
[Add FounderHQ to Next.js](/analytics/recipes/nextjs).
## 5. Let your agent read these docs
Connect the [docs MCP server](/analytics/ai-resources/mcp-server), then name it
in your prompt. Your agent then reads these pages while it writes the code.
## Verify
1. Open the preview or the deployed site, and load a page.
2. In FounderHQ, open **Sequences → Events**.
3. You see a `$pageview` for that page, with its channel.
4. Move to a second page. A second `$pageview` arrives.
Events can take a few seconds to appear. The SDK batches them.
## Troubleshoot
### Nothing appears in Events
Check that the variable exists in the environment you opened. A value
added to production is not present in a preview deployment until you add
it there too.
### Requests to `/i/v2/e` return 401 or 403
The key is wrong, or the domain is not allowed. Preview deployments get
their own domain. Add it to **Allowed origins** on the key, in
**Sequences → Settings → Event API keys**.
# Checkout metadata and payment links (https://www.getfounderhq.com/docs/analytics/revenue/checkout-metadata-and-payment-links)
A payment carries no attribution on its own. Your checkout has to carry two IDs
from the visitor. This page explains the IDs, where to put them, and what to do
when the checkout page is not yours.
## The two IDs
```text
fhq_anonymous_id the visitor, across sessions
fhq_session_id the visit that led to the purchase
```
Read them in the browser:
```ts
import { founderhq } from "@founderhq/events";
const identity = founderhq.checkoutMetadata();
// { fhq_anonymous_id, fhq_session_id }
```
The browser returns an empty object when the visitor has not granted consent.
Treat that as normal and complete the sale without attribution.
If you set an account on the SDK, the same call also returns `fhq_account_key`
and, when one is issued, `fhq_account_context`. Pass them through untouched.
Build the same object on your server with the Node SDK:
```ts
import { checkoutMetadata } from "@founderhq/events-node";
const attribution = checkoutMetadata({
anonymousId: identity.fhq_anonymous_id,
sessionId: identity.fhq_session_id,
});
```
It throws when either ID is empty, so wrap it if a missing ID must not block
the sale.
## Where the IDs go
| How you charge | Where the IDs go |
| --- | --- |
| [Stripe Checkout Session](/analytics/revenue/connect-stripe/checkout-api) | Session `metadata`, plus `subscription_data.metadata` for subscriptions or `payment_intent_data.metadata` for one-time payments |
| [Stripe PaymentIntent](/analytics/revenue/connect-stripe/payment-intent-api) | The PaymentIntent's `metadata` |
| [Stripe Payment Link or Pricing Table](/analytics/revenue/connect-stripe/payment-links) | A minted token in `client_reference_id` |
| [Dodo](/analytics/revenue/connect-dodo) | The `metadata` object at checkout creation |
| [Mobile stores](/analytics/revenue/mobile-purchase-claims) | Not metadata. A UUID in Apple's `appAccountToken` or Google's `obfuscatedExternalAccountId` |
## The renewal trap
Session metadata describes one checkout. It does not travel to next year's
renewal.
Put the metadata on the object that renews as well: the Stripe subscription for
subscriptions, the PaymentIntent for one-time payments. FounderHQ also binds
the provider's customer and subscription IDs on the first payment, so a renewal
with no metadata at all still resolves to the same contact.
FounderHQ works out a renewal's channel from the contact's touches at the time
of the renewal, then freezes it. Nothing later rewrites a payment.
## Hosted pages you cannot edit
A Stripe Payment Link runs on Stripe's domain, so there is no place to put
metadata. Ask the SDK for a token instead:
```ts
const token = await founderhq.paymentLinkToken();
```
- The token starts with `fhqref_` and is valid for 30 days.
- FounderHQ mints it server-side, so attribution does not depend on the buyer
returning to your site.
- It returns `null` when consent is denied, when the visitor opted out, or when
the page origin is not allowed for your publishable key.
Put it in `client_reference_id` on a Payment Link, or in the
`client-reference-id` attribute on a Pricing Table.
## Verify
Start a checkout, then open the object in your provider's dashboard. It shows
both keys, or the link URL carries `client_reference_id`. After payment,
FounderHQ fills in Introduced by and Closed by.
# Connect Apple (native) (https://www.getfounderhq.com/docs/analytics/revenue/connect-apple)
Connect the App Store directly, with no aggregator in between. FounderHQ then
reads Apple's own notifications and Apple's own transaction records. Allow
about fifteen minutes.
You need a [FounderHQ brand](/analytics/getting-started), the FounderHQ SDK in
your app ([iOS](/analytics/sdks/ios) or [React
Native](/analytics/sdks/react-native)), and an App Store Connect account with
the Admin or Account Holder role.
## 1. Create an In-App Purchase key
In App Store Connect, open **Users and Access → Integrations → In-App
Purchase**. Create a key there — not an App Store Connect API team key — and
download the `.p8` file. Apple lets you download it once.
Collect these values while you are on that page and in your app's settings:
| Value | Where it comes from | Example |
| ------------ | ------------------------------------------------ | ------------------- |
| Bundle ID | Your app's bundle identifier | `com.acme.afteryou` |
| Apple app ID | The numeric app ID in App Store Connect | `1234567890` |
| Issuer ID | Above the key list on the In-App Purchase page | a UUID |
| Key ID | Beside the In-App Purchase key you created | `ABC123DEFG` |
## 2. Create the connection in FounderHQ
In FounderHQ, open **Settings → Integrations → Revenue**. Choose the brand,
then Apple App Store. Enter the four values above, and paste the contents of
the `.p8` file. FounderHQ encrypts the key and signs its App Store Server API
calls with it, so it can fetch the authoritative transaction behind every
notification.
FounderHQ then gives you a notification URL:
```text
https://app.getfounderhq.com/api/hooks/payments/apple_native/CONNECTION_ID
```
## 3. Set the notification URL in App Store Connect
Open your app's **App Information** page and find **App Store Server
Notifications**. Paste the FounderHQ URL as the Production Server URL.
New apps get **Version 2** automatically — Apple no longer offers Version 1 to
them. If Apple shows a version choice on an older app, pick Version 2.
## 4. Ask Apple for a test notification
Back in FounderHQ, click **Ask Apple to send a test**. App Store Connect has
no button for this — FounderHQ signs the request with your key and Apple
delivers a signed test notification, usually within a minute. The connection
shows **Connected** the moment it arrives. No payment is needed.
FounderHQ verifies Apple's signature chain, the bundle ID, and the environment
on every notification. It handles these notification types:
```text
SUBSCRIBED DID_RENEW ONE_TIME_CHARGE
REFUND REFUND_REVERSED REFUND_DECLINED
DID_CHANGE_RENEWAL_PREF DID_CHANGE_RENEWAL_STATUS DID_FAIL_TO_RENEW
GRACE_PERIOD_EXPIRED EXPIRED REVOKE
OFFER_REDEEMED PRICE_INCREASE CONSUMPTION_REQUEST
RENEWAL_EXTENDED RENEWAL_EXTENSION
```
Purchases, renewals, and refunds move money. Renewal-preference changes, grace
periods, and price increases update state only.
## Notification history: the safety net
Apple keeps a record of every notification it tried to deliver for your app.
FounderHQ reads that record on a schedule with the same key you already
pasted, and checks every signature again.
Two things follow:
- **A short outage costs you nothing.** If a notification never reached
FounderHQ, the next read picks it up. Apple's own dates are kept, so a
purchase lands on the day it happened, not the day we read it.
- **You do not have to move your notification URL.** App Store Connect holds
one URL. If RevenueCat, Superwall, or another tool already has it, leave it
there — Apple's history records what it sent to them, and FounderHQ reads
the same stream. Skip step 3 and connect with the key alone.
You can also read now instead of waiting: open the connection's menu and
choose **Sync now**.
Nothing changes for the tool that holds the URL. FounderHQ never asks Apple
to send anywhere else, and never touches your subscription setup.
## 5. Pass the FounderHQ token on every purchase
Apple carries one UUID through the purchase and returns it on the notification.
FounderHQ uses it to find the buyer. Ask the SDK for it, then hand it to
StoreKit as `appAccountToken`:
```ts
const prepared = await founderhq.preparePurchase({ source: "app_store" });
// Pass prepared.appAccountToken to your StoreKit purchase as appAccountToken.
await founderhq.observePurchase({
prepared,
purchase: {
source: "app_store",
transactionId: transaction.id,
originalTransactionId: transaction.originalID,
},
});
```
On iOS, `purchase(_:options:)` adds the token for you. See
[mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Already reporting this app through RevenueCat or Superwall?
Connect Apple anyway. FounderHQ allows it, and from that moment Apple's
amounts, taxes, and dates own the money for this app. The other connection
stays where it is: its events are kept as evidence and add nothing to your
totals, so nothing counts twice during the switch, and everything already
recorded stays in your reports. See
[multiple providers](/analytics/revenue/multiple-providers).
## Verify
1. The connection shows **Connected** after Apple's test notification (step 4)
arrives.
2. When the next real purchase happens in your app, open Revenue in FounderHQ.
The purchase shows the channel that brought the customer.
## Troubleshoot
### The connection stays on "Waiting for test"
Apple has not delivered a notification yet. Confirm the URL is saved in App
Store Connect, then click **Ask Apple to send a test** again. A new URL can
take a few minutes to register on Apple's side.
### FounderHQ rejects the notification
The bundle ID on the connection differs from the bundle ID inside Apple's
signed payload, or a Sandbox notification reached a live connection. Fix the
value that does not match.
### Purchases arrive without a channel
The app bought without a prepared token. Call `preparePurchase` before every
purchase, and pass the returned `appAccountToken` to StoreKit.
# Connect Dodo (https://www.getfounderhq.com/docs/analytics/revenue/connect-dodo)
Connect Dodo Payments so FounderHQ records every payment, refund, dispute, and
subscription change. The recommended path is a read-only API key — there is no
webhook to configure, and revenue appears within a few minutes.
You need a [FounderHQ brand](/analytics/getting-started) with the Events SDK
installed, and a live Dodo Payments business you can open the dashboard for.
## Which should I choose?
| Mode | Choose it when | Your revenue appears |
| -------------------- | ------------------------------------------ | -------------------- |
| **Paste an API key** | You want the simplest setup | Within a few minutes |
| **Webhook** | You want to configure every event yourself | In seconds |
## Paste an API key (recommended)
1. In FounderHQ, open **Settings → Integrations → Revenue**.
2. Choose Dodo Payments and the brand. Select **Paste an API key
(recommended)**.
3. Open [Dodo API Keys](https://app.dodopayments.com/developer/api-keys) and
choose **Add API Key**.
4. Leave **Enable write access** off. This makes the key read-only.
5. Copy the live `dp_live_` key into FounderHQ and connect.
FounderHQ checks the key against Dodo's live API and binds the business it
belongs to. A test-mode key is refused. The key is encrypted, never included
in revenue evidence, and can be revoked from Dodo anytime.
There is no webhook setup. FounderHQ checks Dodo every few minutes and reads
payments, refunds, subscriptions, and disputes. Use **Sync now** when you need
an immediate refresh.
## Webhook (manual)
### 1. Create the connection in FounderHQ
In FounderHQ, open **Settings → Integrations → Revenue**. Choose Dodo Payments,
the brand, then select **Webhook**.
Do not enter a business ID. Dodo puts the business on every signed event, and
FounderHQ binds it from the first verified event. After that, events from any
other business are rejected.
FounderHQ gives you a webhook URL:
```text
https://app.getfounderhq.com/api/hooks/payments/dodo/CONNECTION_ID
```
### 2. Add the endpoint in Dodo and select the events
```text
payment.succeeded
refund.succeeded
refund.failed
subscription.active
subscription.cancelled
subscription.expired
subscription.failed
subscription.on_hold
subscription.plan_changed
subscription.renewed
subscription.updated
dispute.accepted
dispute.cancelled
dispute.challenged
dispute.expired
dispute.lost
dispute.opened
dispute.won
```
FounderHQ ignores any other event type.
### 3. Paste the signing secret back
Dodo mints the signing secret, not FounderHQ. Copy it from the Dodo endpoint
and paste it into the FounderHQ connection.
When you rotate the secret later, FounderHQ accepts both the old and the new
secret for 24 hours. That matches Dodo's own rotation window, so no event is
lost mid-rotation.
### 4. Send a test event
Send a signed test event from Dodo. FounderHQ verifies the signature, binds
your business ID, and marks the connection **Connected**.
## Attach attribution at checkout creation
Dodo carries your metadata from checkout into the payment. Build it with the
Node SDK and pass it as the `metadata` object when you create the payment or
subscription.
```ts
// app/api/dodo-checkout/route.ts
import { checkoutMetadata } from "@founderhq/events-node";
export async function POST(request: Request) {
const { identity, productId } = await request.json();
const metadata = checkoutMetadata({
anonymousId: identity.fhq_anonymous_id,
sessionId: identity.fhq_session_id,
});
// Pass `metadata` as the metadata field of your Dodo checkout call.
return Response.json({ productId, metadata });
}
```
The browser supplies `identity` from `founderhq.checkoutMetadata()`. See
[checkout metadata and payment links](/analytics/revenue/checkout-metadata-and-payment-links).
Renewals carry no browser session. FounderHQ binds the Dodo customer and
subscription on the first payment and resolves renewals through that binding.
## What creates revenue, and what does not
| Dodo observation | Effect in FounderHQ |
| ------------------- | ----------------------------------------------------------------------------------- |
| Successful payment | Creates revenue. This covers first payments, renewals, and Indian recurring debits. |
| Successful refund | Subtracts revenue. |
| Subscription change | Updates subscription state and MRR only. It never creates revenue. |
| Dispute change | Stored as evidence. It does not change revenue today. |
Dodo fires a renewal event alongside the first payment. That is why lifecycle
events never create money on their own. The API-key path follows the same rule
when it reconstructs the current resource state.
Selling in India? FounderHQ stores both the charged total and the settlement
amount. Channel revenue uses the charged total.
## Verify
### API key
1. The connection shows **Connected** as soon as Dodo verifies the key.
2. Open Revenue after the first sync. Existing recent payments appear there.
3. A new live payment appears with the channel that brought the buyer, under
**Introduced by** and **Closed by**.
### Webhook
1. Send a signed test event from Dodo. The connection shows **Connected**.
2. Take a payment through your checkout.
3. In FounderHQ, open Revenue and inspect its attribution.
## Troubleshoot
### FounderHQ asks for a live-mode API key
The pasted key belongs to Dodo test mode. Open the live Dodo dashboard and
create a read-only key there.
### FounderHQ says the key belongs to another Dodo business
The connection is already bound to another business. Use a key for that
business, or create a separate connection for the other one.
### Dodo shows "Invalid signature"
The pasted webhook secret is wrong or was rotated more than 24 hours ago. Copy
the current secret from Dodo and paste it again.
### The webhook connection stays on "Waiting for test"
No signed event has arrived yet. Send a test event from the Dodo endpoint.
# Connect Google Play (native) (https://www.getfounderhq.com/docs/analytics/revenue/connect-google-play)
Connect Google Play directly, with no aggregator in between. You give FounderHQ
your package name. FounderHQ gives you two values to paste into Play Console.
That is the whole setup — no Google Cloud account, and nothing to configure
outside Play Console. Allow about ten minutes.
Already send notifications to RevenueCat or another tool? Keep your topic and
[read it side by side](#already-sending-notifications-to-another-tool).
You need a [FounderHQ brand](/analytics/getting-started), the FounderHQ SDK in
your app ([Android](/analytics/sdks/android) or [React
Native](/analytics/sdks/react-native)), and a Play Console account that can
edit users and monetization setup.
## 1. Create the connection in FounderHQ
In FounderHQ, open **Settings → Integrations → Revenue**. Choose Google Play
and the brand.
FounderHQ needs one value:
| Value | Meaning | Example |
| ------------ | ------------------------- | ------------------- |
| Package name | Your app's application ID | `com.acme.afteryou` |
Click Connect. FounderHQ prepares your app's notification mailbox and shows you
two values to copy: a **topic name** and a **FounderHQ address**.
## 2. Turn on notifications in Play Console
In Play Console, open **Monetization setup** for your app.
1. Paste the topic name from FounderHQ.
2. Choose **Subscriptions, voided purchases and all one-time products**.
3. Click **Save**.
4. Click **Send test notification**.
Refunds and chargebacks arrive as voided purchases, so keep that selection.
## 3. Invite FounderHQ to your app
A Google notification only says that something changed. It never carries the
money. FounderHQ reads the real purchase from the Play Developer API, so it
needs read access to your app.
In Play Console, open **Users and permissions** and invite the FounderHQ
address shown on the connection. Give it access to your app, with permission to
**view financial data and orders**.
Without this invite, notifications arrive and nothing can be confirmed.
## Already sending notifications to another tool?
Play Console holds one topic. RevenueCat, or another tool you already use, may
hold it. Keep it. A topic feeds as many readers as you like.
On the connect screen, under the package name, choose **My notifications
already go to another tool's topic**. Paste the topic's full name, which starts
with `projects/`. Find it in Google Cloud under **Pub/Sub → Topics**.
Then give FounderHQ permission to read it:
1. In Google Cloud, open **Pub/Sub → your topic → Permissions**.
2. Click **Add principal**.
3. Paste the FounderHQ address shown on the connection.
4. Choose the role **Pub/Sub Subscriber**. Save.
FounderHQ adds one reader of its own beside your other tool's. Both tools get
every notification.
Nothing changes in Play Console, and nothing changes on your topic. FounderHQ
never edits your notification setup, and disconnecting removes only
FounderHQ's own reader — your topic stays.
You still need step 3, the Play Console invite: the notification says that
something changed, and the invite is what lets FounderHQ read the money. Step 2
is the part you skip.
The connection turns on with the next notification for your app. Click **Send
test notification** in Play Console if you would rather not wait.
## 4. Pass the FounderHQ token on every purchase
Google carries one opaque ID through the purchase and returns it on the
verified purchase record. FounderHQ uses it to find the buyer.
```ts
const prepared = await founderhq.preparePurchase({ source: "google_play" });
// Pass prepared.obfuscatedExternalAccountId to your billing flow.
await founderhq.observePurchase({
prepared,
purchase: {
source: "google_play",
purchaseToken: purchase.purchaseToken,
orderId: purchase.orderId,
},
});
```
On Android, `applyPurchaseContext(builder, prepared)` sets
`setObfuscatedAccountId` for you. See
[mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## Already reporting this app through RevenueCat or Superwall?
Connect Google Play anyway. FounderHQ allows it, and from that moment Play's
amounts, taxes, and dates own the money for this app. The other connection
stays where it is: its events are kept as evidence and add nothing to your
totals, so nothing counts twice during the switch, and everything already
recorded stays in your reports. See
[multiple providers](/analytics/revenue/multiple-providers).
## Verify
1. The connection shows **Connected** in FounderHQ within a minute of the test
notification. Keep the setup window open while you wait.
2. Buy a test subscription in your app with a licensed test account.
3. In FounderHQ, open Revenue. The purchase shows the channel that brought the
customer.
## Troubleshoot
### The test notification never arrives
Check that you clicked **Save** in Monetization setup after pasting the topic
name — Play Console does not save on its own. Then click **Send test
notification** again. The test alone turns the connection on; the invite in
Users and permissions matters for real purchases, which FounderHQ confirms
against your app before recording money.
### Notifications arrive but no revenue appears
FounderHQ cannot read the purchase from the Play Developer API. Confirm the
FounderHQ address still has access to the app in Play Console, and that its
access includes financial data and orders.
### Purchases arrive without a channel
The billing flow ran without a prepared token. Call `preparePurchase` first and
apply the returned `obfuscatedExternalAccountId`.
# Connect modes (https://www.getfounderhq.com/docs/analytics/revenue/connect-modes)
Stripe and Dodo both support webhook-free connections. Every mode feeds the
same revenue reports.
Open **Settings → Integrations → Revenue**, choose your brand, then choose
your payment provider.
## Which should I choose?
| Mode | Choose it when | Your revenue appears |
| ------------------------- | ------------------------------------------ | -------------------- |
| **Paste an API key** | You want the simplest setup | Within a few minutes |
| **Webhook** | You want to configure every event yourself | In seconds |
| **Connect with Stripe** | Shown when one-click connect is available | In seconds |
## Paste an API key (recommended)
For Stripe, paste a restricted `rk_` key with read access to Account and
Events. For Dodo, create a live API key and leave **Enable write access** off.
There is no webhook to configure, and your revenue appears within a few
minutes.
The key is encrypted and can be revoked from the provider anytime. FounderHQ
refuses unrestricted Stripe `sk_` keys and Dodo test-mode keys.
## Webhook
FounderHQ gives you an address. Add it in Stripe, choose the listed events, and
paste the signing secret back. Stripe sends new revenue in seconds.
Choose this when you want to configure and test every event yourself. Follow
the manual steps on [Connect Stripe](/analytics/revenue/connect-stripe) or
[Connect Dodo](/analytics/revenue/connect-dodo).
## Connect with Stripe (one click)
When your Payments settings show this option, it is the fastest path: click
once, approve access on Stripe's page, and your revenue appears in seconds.
You can revoke FounderHQ from your Stripe dashboard anytime. If you don't see
it, use a read-only key — the data FounderHQ records is identical.
## Other providers
RevenueCat, Apple App Store, and Google Play keep their existing setup.
Superwall connects with your app's store identity and records Superwall's
amounts. See [connect Superwall](/analytics/revenue/connect-superwall).
## The part that does not change
Late updates still land on the day the customer paid. Switching how Stripe or
Dodo is connected does not duplicate revenue already recorded.
## Next
- [Connect Stripe](/analytics/revenue/connect-stripe)
- [Connect Dodo](/analytics/revenue/connect-dodo)
- [Generic revenue API](/analytics/revenue/generic-revenue-api)
# Connect RevenueCat (https://www.getfounderhq.com/docs/analytics/revenue/connect-revenuecat)
Connect RevenueCat so FounderHQ records App Store and Google Play purchases
that RevenueCat already manages. This takes about ten minutes.
You need a [FounderHQ brand](/analytics/getting-started), the FounderHQ mobile
SDK in your app ([React Native](/analytics/sdks/react-native),
[iOS](/analytics/sdks/ios), [Android](/analytics/sdks/android)), and a
RevenueCat project.
## 1. Follow the identity contract
This is the part that decides whether the connection works at all.
**The FounderHQ mobile UUID is the RevenueCat App User ID.** Not a copy of it.
Not a mapped value. The same string.
Hand your RevenueCat client to the FounderHQ SDK at start-up, and the SDK keeps
the two identities equal for you:
```ts
import Purchases from "react-native-purchases";
import { FounderHqEvents } from "@founderhq/events-react-native";
export const founderhq = new FounderHqEvents("fhq_pk_XXXX", {
revenueCat: {
configure: (appUserId) =>
Purchases.configure({ apiKey: "rc_XXXX", appUserId }),
logIn: (appUserId) => Purchases.logIn(appUserId),
},
});
```
The SDK then:
- configures RevenueCat with the FounderHQ UUID at start-up,
- calls RevenueCat `logIn` when you identify a person,
- calls `logIn` with a fresh UUID when you reset.
`logOut()` makes RevenueCat mint its own anonymous ID. FounderHQ then cannot
map the customer back to a contact, and the purchases arrive unattributed. To
sign a person out, use the FounderHQ SDK's reset. It logs RevenueCat in as the
new guest.
## 2. Create the connection in FounderHQ
In FounderHQ, open **Settings → Integrations → Revenue**. Choose RevenueCat,
the brand.
Enter the App Store bundle ID, the Google Play package name, or both, for the apps this
RevenueCat project sells.
FounderHQ gives you a webhook URL:
```text
https://app.getfounderhq.com/api/hooks/payments/revenuecat/CONNECTION_ID
```
## 3. Add the webhook in RevenueCat
In RevenueCat, open Integrations and add a webhook with that URL.
RevenueCat authenticates with an Authorization header value that you choose.
Enter the same value in both places: the RevenueCat webhook and the FounderHQ
connection. Treat it like a password.
Match the environments. A test connection accepts sandbox events. A live
connection accepts production events.
## 4. Know which events FounderHQ reads
```text
INITIAL_PURCHASE NON_RENEWING_PURCHASE RENEWAL
CANCELLATION UNCANCELLATION BILLING_ISSUE
SUBSCRIPTION_PAUSED PRODUCT_CHANGE EXPIRATION
REFUND TRANSFER TEMPORARY_ENTITLEMENT_GRANT
PRICE_INCREASE_CONSENT_REQUIRED PRICE_INCREASE_CONSENT_APPROVED
```
Purchases, renewals, and refunds move money. Cancellations, billing issues,
pauses, and price-consent events update state only.
## 5. Wrap the purchase call
Wrap your existing RevenueCat purchase so the store transaction reaches
FounderHQ:
```ts
const result = await founderhq.purchaseWithRevenueCat(() =>
Purchases.purchasePackage(pkg),
);
```
The wrapper returns your original result unchanged. See
[mobile purchase claims](/analytics/revenue/mobile-purchase-claims) for the full
purchase flow, including accounts.
## Verify
1. Send a test webhook from RevenueCat. The connection shows **Connected**.
2. Buy a sandbox subscription in your app.
3. In FounderHQ, open Revenue. The purchase shows the channel that brought the
customer.
## Troubleshoot
### RevenueCat shows "Invalid authorization"
The Authorization header value in RevenueCat differs from the value stored on
the connection. Set both to the same string.
### RevenueCat shows "This endpoint only accepts production events."
A sandbox event reached a live connection, or the reverse. Create a connection
in the matching environment.
### Purchases arrive with no contact
Something called `Purchases.logOut()`, or configured RevenueCat with your own
user ID. Remove those calls and let the FounderHQ SDK own the App User ID.
## When you also sell through Stripe or a native store
RevenueCat is a transport, not the last word on the money. A direct Apple,
Google Play, or Stripe connection wins for the same purchase.
You can add one at any time, for an app RevenueCat already reports. FounderHQ
allows it, hands the money to the store from that moment, and leaves this
connection in place as evidence — no downtime, no double counting, and nothing
already recorded is lost. See
[multiple providers](/analytics/revenue/multiple-providers).
# Connect Superwall (https://www.getfounderhq.com/docs/analytics/revenue/connect-superwall)
Connect Superwall so FounderHQ records the purchases your paywalls make. It
takes your app's store identity and the signing secret Superwall gives you.
Allow about five minutes.
You need a [FounderHQ brand](/analytics/getting-started), the FounderHQ SDK in
your app ([iOS](/analytics/sdks/ios) or [React
Native](/analytics/sdks/react-native)), and Superwall handling your purchases.
When RevenueCat manages the subscriptions and Superwall runs in observer mode,
RevenueCat sees every purchase and Superwall's own webhooks may not fire.
Connect [RevenueCat](/analytics/revenue/connect-revenuecat) and stop here — one
app connects once.
## What you are deciding
Superwall reports purchases it did not charge, and FounderHQ takes Superwall
at its word. That is the same trust it extends to RevenueCat, and it is enough
to see your revenue, your channels, and your MRR from day one. Three things
are worth knowing before you connect.
**Refunds arrive as negative amounts, and FounderHQ subtracts them.** Superwall
has no separate refund event: a give-back shows up as a negative amount on
whichever event carries it, and FounderHQ reads the sign, not the label.
**If Superwall misses an event, it is gone.** There is no way to ask Superwall
to resend an event you never received, and no history to catch up from.
Superwall retries a failed delivery for you, so this is rare — but when it
happens, that purchase stays missing.
**The direct store connections are stronger.** [Apple App
Store](/analytics/revenue/connect-apple) and [Google
Play](/analytics/revenue/connect-google-play) are signed by the stores
themselves and can fill in history. Connect them where you can. If you add one
later for this app, it takes over the money automatically — see
[below](#adding-a-store-connection-later).
## 1. Create the connection in FounderHQ
In FounderHQ, open **Settings → Integrations → Revenue**. Choose the brand,
then Superwall. Read the screen, choose **Continue**, and name the app: the
**App Store bundle ID**, the **Google Play package name**, or both — whichever
stores Superwall sells through.
Those names also reserve the app. If this app already reports to FounderHQ
through RevenueCat or a direct store connection, FounderHQ refuses the second
connection and names the one that holds it. Two reporters would count every
purchase twice.
FounderHQ gives you a webhook address:
```text
https://app.getfounderhq.com/api/hooks/payments/superwall/CONNECTION_ID
```
## 2. Add the webhook in Superwall
In Superwall, open **Integrations → Webhooks** and add an endpoint with that
address. Select all nine events:
```text
initial_purchase renewal non_renewing_purchase
product_change cancellation uncancellation
billing_issue subscription_paused expiration
```
Superwall shows a signing secret starting with `whsec_`. Copy it back into
FounderHQ and save.
## 3. Pass the FounderHQ token on every purchase
FounderHQ needs one value to know which visitor bought:
```ts
const prepared = await founderhq.preparePurchase({ source: "app_store" });
// 1. Superwall forwards user attributes on every webhook.
Superwall.shared.setUserAttributes(["fhq_visitor": prepared.appAccountToken])
// 2. Pass the same value to StoreKit as appAccountToken, so Apple returns it
// too — a direct App Store connection reads it from there.
```
Both carry the same UUID, and either one is enough. See [mobile purchase
claims](/analytics/revenue/mobile-purchase-claims) for the full flow, including
accounts.
## What FounderHQ records
| Superwall reports | FounderHQ records |
| --------------------- | ------------------------------------------------------------------ |
| App Store purchases | Superwall's amount and currency |
| Google Play purchases | Superwall's amount and currency |
| Stripe purchases | Ignored — connect Stripe |
| Refunds | The negative amount on the event |
| Lifecycle events | Cancellations, pauses, billing issues, expirations move state only |
The price Superwall shows on its own dashboard is converted to US dollars and
its proceeds are estimated. FounderHQ never uses either. It reads only the
amount in the currency your customer actually paid.
Two more rules worth knowing:
- **Family Sharing.** A family member's renewal arrives at zero. It updates the
subscription and adds no revenue.
- **Refunds for purchases FounderHQ never saw.** If a refund arrives for a
purchase from before you connected, FounderHQ records the refund on the
connection but does not subtract it — the purchase it reverses was never in
your totals, so subtracting it would undercount your revenue.
**Stripe.** Connect [Stripe](/analytics/revenue/connect-stripe) directly.
Superwall's Stripe events carry no charge reference, so FounderHQ cannot tell
them apart from the ones Stripe already sends, and recording both would double
your totals.
## Adding a store connection later
This is the upgrade. Connect [Apple App
Store](/analytics/revenue/connect-apple) or [Google
Play](/analytics/revenue/connect-google-play) for this app whenever you are
ready. FounderHQ allows it, and from that moment the store owns the money:
- The store's amounts, taxes, and dates replace Superwall's.
- Superwall stays connected. Its events are kept as evidence and add nothing to
your totals, so nothing is counted twice during the switch.
- Everything already recorded stays in your reports.
The reverse is not allowed: once a store connection owns an app, FounderHQ
refuses a second reporter for it. Disconnect the store connection first if you
really want to go back.
## Verify
1. Save the signing secret. The connection shows **Waiting for test**.
2. Make a purchase in your app. A TestFlight purchase works.
3. The connection shows **Connected**, and the purchase appears in Revenue with
the channel that brought the customer.
Superwall has no send-a-test-event button, so a real purchase is what finishes
setup. This is the one connector that cannot turn on before your first sale.
## Troubleshoot
### Amounts look different from Superwall's dashboard
Superwall's dashboard converts everything to US dollars using its own rates.
FounderHQ records what your customer paid, in their currency, and converts with
published daily rates on the day of the purchase.
### The connection shows "This endpoint only accepts production events."
A sandbox event reached a live connection, or the reverse. Create a connection
in the matching environment.
### Nothing arrives at all
Confirm the endpoint in **Superwall → Integrations → Webhooks** has the
FounderHQ address and all nine events selected, and that the signing secret in
FounderHQ is the one Superwall shows for that endpoint.
### Purchases arrive without a channel
The app did not set `fhq_visitor` before the purchase. Call `preparePurchase`
and pass the value to `setUserAttributes` when your app starts, and to StoreKit
on every purchase.
# Generic revenue API (https://www.getfounderhq.com/docs/analytics/revenue/generic-revenue-api)
Send payments and refunds from any processor FounderHQ has no connection for.
One request per money event.
You need a [secret key](/analytics/getting-started) (`fhq_sk_XXXX`) that is scoped
to one brand and may write events. A publishable key is rejected. A key with no
brand is rejected.
## Endpoint
```text
POST https://i.getfounderhq.com/api/revenue
authorization: Bearer fhq_sk_XXXX
content-type: application/json
```
FounderHQ answers `202` as soon as the command is stored. Normalization,
matching, and attribution happen after that, so a slow ledger never slows your
checkout.
Using the Node SDK? `events.captureRevenue(command)` posts the same body and
retries for you.
## Example
```bash
curl -X POST https://i.getfounderhq.com/api/revenue \
-H "authorization: Bearer fhq_sk_XXXX" \
-H "content-type: application/json" \
-d '{
"idempotencyKey": "acme:payment:pay_ABC123",
"transactionId": "pay_ABC123",
"transactionRefType": "payment_intent",
"kind": "payment",
"amountMinor": "4900",
"currency": "USD",
"occurredAt": "2026-05-23T09:15:00.000Z",
"paymentRail": "razorpay",
"environment": "live",
"customerEmail": "jane@acme.com",
"checkoutVisitorId": "6b1f0d64-1f0e-4e5a-9a2a-6b0f1d8c2e77",
"providerCustomerId": "cust_42",
"providerSubscriptionId": "sub_42",
"subscription": {
"status": "active",
"interval": "P1M",
"currentPeriodEnd": "2026-06-23T09:15:00.000Z",
"plan": "growth"
}
}'
```
## Response
```json
{
"ok": true,
"deliveryId": "DELIVERY_ID",
"status": "QUEUED",
"created": true
}
```
| Field | Meaning |
| --- | --- |
| `ok` | Always `true` on a `202`. |
| `deliveryId` | FounderHQ's ID for this command. Log it. |
| `status` | `QUEUED`, `PROCESSING`, `PROCESSED`, or `FAILED`. |
| `created` | `true` for a new command. `false` when you replayed one FounderHQ already had. |
## Idempotency
`idempotencyKey` identifies the delivery. Reuse the same key on every retry of
the same money event.
- Same key, same body: FounderHQ returns `202` with `created: false`. Nothing
is counted twice.
- Same key, different body: FounderHQ returns `409`. Pick a new key, or send
the body you sent the first time.
- Different key, same `transactionId`, `transactionRefType`, and `kind`:
FounderHQ still counts the money once. The transaction is what dedupes the
economics.
Keys are scoped to the API key you send them with.
## Fields
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `idempotencyKey` | string, 1–512 | yes | Stable ID for this delivery. Retries reuse it. |
| `transactionId` | string, 1–512 | yes | The processor's transaction ID. FounderHQ dedupes the money by it. |
| `transactionRefType` | enum | no, default `transaction` | What `transactionId` points at: `payment_intent`, `charge`, `invoice_payment`, `refund`, `transaction`, `order`, `dispute`, `credit_note`. |
| `kind` | enum | no, default `payment` | `payment`, `refund`, `dispute_lost`, or `credit_note`. |
| `amountMinor` | integer, string or number | yes | Amount in minor units. `4900` is $49.00. Never `0`. Signed or unsigned: FounderHQ normalizes the sign from `kind`. |
| `currency` | 3 letters | yes | The charged currency, for example `USD`. |
| `taxMinor` | integer | no | Tax inside the amount. |
| `feeMinor` | integer | no | The processor's fee. |
| `settlementAmountMinor` | integer | no | What actually reached your bank. |
| `settlementCurrency` | 3 letters | no | The currency of that settlement. |
| `reportingCurrency` | 3 letters | no | The currency you want this payment reported in. |
| `fxRate` | decimal, string or number | no | The rate used to reach the reporting currency. |
| `occurredAt` | ISO 8601 datetime | no | When the money moved. Without it, FounderHQ uses the time it received the command, and marks the date as a fallback. Send it. |
| `providerRevisionAt` | ISO 8601 datetime | no | When the processor last revised this record. FounderHQ uses it to decide which correction is newer. |
| `providerRevisionKey` | string, 1–512 | no | Your ID for that revision. Defaults to the idempotency key. |
| `paymentRail` | string, 1–64 | no, default `generic` | The processor this money moved on, for example `razorpay`. |
| `merchantAccountId` | string, 1–256 | no | Your merchant or account ID with that processor. |
| `environment` | string, 1–32 | no, default `live` | The environment this money moved in. FounderHQ stores it on the payment, so test money stays labelled as test. |
| `originalTransactionId` | string, 1–512 | required for reversals | The payment this refund, lost dispute, or credit note cancels. |
| `originalRefType` | enum | no, default `transaction` | What `originalTransactionId` points at: `payment_intent`, `charge`, `invoice_payment`, `transaction`, `order`. |
| `checkoutVisitorId` | string, 1–400 | no | The `fhq_anonymous_id` your checkout captured. This is what ties the payment to a channel. |
| `checkoutAccountContext` | string, 1–2048 | no | The `fhq_account_context` token, when the buyer paid on behalf of an account. |
| `providerCustomerId` | string, 1–512 | no | The processor's customer ID. It binds renewals to the same contact. |
| `providerSubscriptionId` | string, 1–512 | no | The processor's subscription ID. Required when you send `subscription`. |
| `customerEmail` | email | no | The payer's email. FounderHQ uses it to find the contact when no visitor ID is present. |
| `historicalImport` | boolean | no, default `false` | Marks a backfilled payment. The amount counts, but with no touch it stays unmatched instead of counting as direct traffic. |
| `metadata` | object | no | Your own keys. Stored with the payment. |
| `attributionWindowDays` | `30`, `60`, `90`, or `180` | no, default `90` | How far back FounderHQ looks for the touches that earn this payment. |
| `subscription` | object | no | Recurring state. See below. |
### `subscription`
Send this block on a recurring payment and FounderHQ drives MRR from it, the
same way a connected provider does.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `status` | enum | no, default `active` | `trialing`, `active`, `past_due`, `paused`, `canceled`, or `expired`. |
| `priceMinor` | integer | no | The plan price in minor units. Defaults to `amountMinor` on a payment. |
| `interval` | ISO 8601 period, or `null` | no | `P1M` for monthly, `P1Y` for yearly. Without it, MRR is `0` and the period stays unknown. |
| `currentPeriodEnd` | ISO 8601 datetime, or `null` | no | When this paid period ends. |
| `quantity` | integer, 1–10000 | no, default `1` | Seats or units. |
| `plan` | string, 1–200, or `null` | no | Your plan name, for example `growth`. |
## Rules FounderHQ enforces
| Rule | Why |
| --- | --- |
| `amountMinor` must not be `0` | A zero amount is not a money event. |
| A refund, lost dispute, or credit note must carry `originalTransactionId` | A reversal has to say what it reverses. |
| A payment must not carry `originalTransactionId` | A payment reverses nothing. |
| `subscription` requires `providerSubscriptionId` | MRR needs a subscription to attach to. |
## Errors
| Status | Meaning |
| --- | --- |
| `400` | The body failed a rule. The message names the first field that failed. |
| `401` | The key is missing, wrong, or not a secret key. |
| `403` | The key is not scoped to a brand, or cannot write events. |
| `409` | This idempotency key already exists with a different body. |
## Verify
1. Send a test payment with `"environment": "test"`.
2. Confirm the response is `202` with `"created": true`.
3. Send the same body again. The response says `"created": false`.
4. In FounderHQ, open Revenue. The payment shows your amount, and the channel
that brought the customer when you sent `checkoutVisitorId`.
# Revenue overview (https://www.getfounderhq.com/docs/analytics/revenue)
Revenue attribution answers one question: which channel and which content paid you.
FounderHQ joins every payment to the contact who paid it. It then reads that
contact's touch history and prints two answers on the payment:
- **Introduced by** — the touch that first brought the contact to you.
- **Closed by** — the last touch before the money arrived.
Refunds, lost disputes, and credit notes subtract from the same numbers.
Subscriptions also carry MRR, so a renewal keeps counting for the channel that
won the customer.
Every payment lands on the day the money moved, not the day FounderHQ heard
about it. A late webhook still lands on the right day.
## Two ways to get revenue in
**Connect your payment provider.** Open **Settings → Integrations → Revenue**
and choose the brand the account belongs to. Stripe can connect in one click,
with a read-only key, or by webhook. Dodo can connect with a read-only API key
or by webhook. Providers you can connect today:
| Provider | Page |
| --------------- | ------------------------------------------------------------- |
| Stripe | [Connect Stripe](/analytics/revenue/connect-stripe) |
| Apple App Store | [Connect Apple](/analytics/revenue/connect-apple) |
| Google Play | [Connect Google Play](/analytics/revenue/connect-google-play) |
| Dodo Payments | [Connect Dodo](/analytics/revenue/connect-dodo) |
| RevenueCat | [Connect RevenueCat](/analytics/revenue/connect-revenuecat) |
| Superwall | [Connect Superwall](/analytics/revenue/connect-superwall) |
**Send the payment yourself.** Any other processor goes through the
[generic revenue API](/analytics/revenue/generic-revenue-api). You post one command
per payment or refund with a secret key. The command drives the same reports,
the same refunds, and the same MRR as a connected provider.
Read [connect modes](/analytics/revenue/connect-modes) before you choose.
## What FounderHQ needs from your checkout
A payment carries no attribution on its own. Your checkout must carry two IDs
from the visitor's browser or app:
```text
fhq_anonymous_id
fhq_session_id
```
Where those IDs go depends on the provider and on how you take payments. See
[checkout metadata and payment links](/analytics/revenue/checkout-metadata-and-payment-links).
Mobile stores work differently. The purchase carries a FounderHQ UUID in
Apple's `appAccountToken` or Google's `obfuscatedExternalAccountId`. See
[mobile purchase claims](/analytics/revenue/mobile-purchase-claims).
## How connections stay healthy
A key gets revoked. Somebody removes an invite. A webhook endpoint disappears
in a dashboard cleanup. None of that announces itself, and a connection that
looks fine while it quietly reports nothing is worse than one that is clearly
broken.
So FounderHQ checks every connected account a few times a day. The check is
read-only: it asks the provider whether the access you gave still works, and it
changes nothing on your side.
When a check fails, the connection shows **Needs attention** in
**Settings → Integrations → Revenue**, with the reason and the fix on the row —
for example, save a new read-only key, or re-invite FounderHQ in Play Console.
Nothing is switched off: the connection keeps running, and past revenue stays
exactly as it is. The badge clears itself the moment a check passes again.
For providers that only send webhooks, there is nothing to ask. FounderHQ
watches for silence instead, and says something only when an account that used
to send events stops for two weeks. A quiet month does not raise the badge.
## Where to go next
- [Attribution (Introduced by / Closed by)](/analytics/concepts/attribution) — how
the two answers are chosen.
- [Multiple providers](/analytics/revenue/multiple-providers) — what happens when
two connections report the same purchase.
- [Generic revenue API](/analytics/revenue/generic-revenue-api) — the full field
reference.
# Mobile purchase claims (https://www.getfounderhq.com/docs/analytics/revenue/mobile-purchase-claims)
Tie an App Store or Google Play purchase to the person who made it, and to the
account it should fund. Allow about fifteen minutes.
Install the FounderHQ SDK ([React Native](/analytics/sdks/react-native),
[iOS](/analytics/sdks/ios), or [Android](/analytics/sdks/android)) and connect a store:
[RevenueCat](/analytics/revenue/connect-revenuecat),
[Superwall](/analytics/revenue/connect-superwall),
[Apple](/analytics/revenue/connect-apple), or
[Google Play](/analytics/revenue/connect-google-play).
A store purchase carries no browser session and no metadata field you can fill.
It carries one UUID. The SDK mints that UUID, records what it stands for, and
hands it to the store. When the store notifies FounderHQ, the UUID leads back
to the buyer.
## 1. Prepare before you open the purchase sheet
```ts
const prepared = await founderhq.preparePurchase({ source: "app_store" });
```
`prepared` gives you the field your store library needs:
| Store | Field to pass | Where it goes |
| --- | --- | --- |
| App Store | `prepared.appAccountToken` | StoreKit purchase option `appAccountToken` |
| Google Play | `prepared.obfuscatedExternalAccountId` | `BillingFlowParams.Builder.setObfuscatedAccountId` |
| Superwall | `prepared.appAccountToken` | Superwall user attribute `fhq_visitor`, plus the store field above |
`preparePurchase` waits a moment for FounderHQ to acknowledge the context, then
returns anyway. Checkout never blocks on the network.
Whatever account is set on the SDK at this moment is frozen into the context.
Changing accounts later does not move this purchase.
## 2. Report the purchase result
```ts
await founderhq.observePurchase({
prepared,
purchase: {
source: "app_store",
transactionId: transaction.id,
originalTransactionId: transaction.originalID,
},
});
```
For Google Play, send `{ source: "google_play", purchaseToken, orderId }`.
The source you observe must match the source you prepared.
Using RevenueCat? Wrap the purchase instead, and the SDK does both steps:
```ts
const result = await founderhq.purchaseWithRevenueCat(() =>
Purchases.purchasePackage(pkg),
);
```
## 3. Move an existing subscription to an account
Sometimes a person already pays, and later that subscription should fund a
team, family, or workspace. That is a deliberate product action, so it needs a
deliberate call:
```ts
await founderhq.claimSubscription({
purchase: {
source: "app_store",
transactionId: transaction.id,
originalTransactionId: transaction.originalID,
},
confirmation: "move_future_revenue",
});
```
Payments that already happened keep the owner they had. The new owner starts at
the moment FounderHQ accepts the claim. MRR moves; history does not. Refunds
always follow the original payment.
Never call this from a listener, a restore, an entitlement refresh, or a
sign-in. Call it from a screen where the person asked for it.
## The RevenueCat identity rule
If you use RevenueCat, the FounderHQ UUID is the RevenueCat App User ID. Let
the SDK configure and log in RevenueCat, and never call
`Purchases.logOut()`. See [Connect RevenueCat](/analytics/revenue/connect-revenuecat).
## The same calls on every platform
| What you do | React Native | iOS | Android |
| --- | --- | --- | --- |
| Prepare | `preparePurchase` | `preparePurchase(source:)` | `preparePurchase(source, completion)` |
| Apply the token | pass `prepared` fields | `purchase(_:options:)` adds it | `applyPurchaseContext(builder, prepared)` |
| Report the result | `observePurchase` | `observePurchase(_:prepared:)` | `observePurchase(purchase, prepared)` |
| Wrap RevenueCat | `purchaseWithRevenueCat` | `purchaseWithRevenueCat` | `revenueCatPurchaseCallback` |
| Move a subscription | `claimSubscription` | `claimSubscription(_:confirmation:)` | `claimSubscription(purchase, confirmation)` |
## From your backend
Your server can do two things with a secret key, through the
[Node SDK](/analytics/sdks/node):
- `accountContextToken({ account, userId, idempotencyKey })` issues short-lived
proof that this person may act for this account. It moves no money.
- `claimMobileSubscription({ account, userId, purchase, confirmation, idempotencyKey })`
moves a subscription's future revenue to an account, with the same
`"move_future_revenue"` confirmation and the same rules as the mobile call.
Neither call creates the account or the membership. Create those first with
`upsertAccount` and `accountMembership`.
## Verify
1. Buy a sandbox subscription in the app.
2. In FounderHQ, open Revenue. The purchase shows the buyer as a contact, and
the channel that brought them.
3. After a transfer claim, the next renewal counts for the account, and the
earlier payments still count for the person.
## Troubleshoot
### The purchase has no contact
The app purchased without preparing. Call `preparePurchase` first, and pass the
token to the store library.
### "Observed purchase source does not match its preparation"
You prepared for one store and observed another. Prepare with the same source
you buy with.
### A RevenueCat purchase reports no transaction
Some observer and custom-completion modes return no store transaction. The SDK
will not guess from entitlements. Claim it later with a verified transaction ID
or purchase token.
# Multiple providers (authority and dedupe) (https://www.getfounderhq.com/docs/analytics/revenue/multiple-providers)
You can connect more than one provider to the same brand. Many founders do:
RevenueCat for the apps, Stripe for the web.
Sometimes two connections describe the same purchase. This page explains which
one FounderHQ believes, and why your totals do not double.
## One purchase, two reporters
RevenueCat is a reporter, not a store. Every purchase it tells you about really
happened at Apple, at Google Play, or at Stripe. So if you connect RevenueCat
and the underlying provider, both send you the same sale.
FounderHQ picks one source of truth per purchase:
| You have connected | Who owns the money | What happens to the other copy |
| --- | --- | --- |
| RevenueCat and Stripe, for a Stripe purchase | Stripe | FounderHQ ignores the RevenueCat event |
| RevenueCat and Apple, for an App Store purchase | Apple | FounderHQ keeps the RevenueCat event as evidence, but it adds no money |
| RevenueCat and Google Play, for a Play purchase | Google Play | Same: kept as evidence, adds no money |
| RevenueCat only | RevenueCat | It is the only reporter, so it owns the money |
| Superwall only | Superwall | It is the only reporter, so its amounts are the money |
| Superwall and a store connection, for the same app | The store | Superwall's copy is kept as evidence and adds no money |
The rule is simple: whoever charged the card wins. The closer a source is to
the store, the better its amounts, its taxes, and its timing.
When you connect a native store after running on RevenueCat or Superwall,
FounderHQ retires the reporter's copy of that subscription's state. MRR does
not count twice during the switch.
## Adding a store connection is always allowed
You can connect [Apple App Store](/analytics/revenue/connect-apple) or [Google
Play](/analytics/revenue/connect-google-play) for an app that already reports
through RevenueCat or Superwall. FounderHQ allows it, hands the money to the
store from that moment, and leaves the reporter connected as evidence. That is
the migration path off a reporter, and it takes no downtime.
The other direction is refused. Once a store connection owns an app, adding a
second reporter for it — or a second store connection — would be two claims on
the same purchase with no way to choose. FounderHQ names the connection that
holds the app and asks you to disconnect it first.
## How FounderHQ knows it is the same purchase
FounderHQ matches on the store's own references, never on the reporter's event
ID:
- Apple: the transaction ID, with the original transaction ID for the
subscription.
- Google Play: the purchase token, with the order ID for each charge.
- Stripe: the PaymentIntent.
Those references are identical no matter who reports them. A RevenueCat event
ID and an Apple notification ID are not, which is why they are never used for
matching.
## Superwall is a special case
[Superwall](/analytics/revenue/connect-superwall) reports purchases it did not
charge. FounderHQ records the amount and currency Superwall reports —
Superwall's word, ranked below RevenueCat's and well below a store's. The
upgrade is connecting the store itself: the store takes the money over, and
Superwall stays connected as evidence.
Superwall's Stripe events are always ignored: they carry no charge reference,
so FounderHQ cannot tell them apart from the ones Stripe already sends. Connect
[Stripe](/analytics/revenue/connect-stripe) directly.
Superwall's Google Play events are recorded only while no Google Play
connection owns the app. Superwall makes up its own reference numbers for Play
purchases, so once Google Play is connected there is no way to tell the two
copies of a sale apart. Google Play takes the money and Superwall's Play events
become evidence.
## Two Stripe accounts, or one brand per product
Separate connections stay separate. Each connection carries its own account or
business ID, and FounderHQ rejects events from any other one. Connect as many
as you sell through.
## What to do
- Selling on the web and in an app? Connect Stripe and RevenueCat. Nothing
double-counts.
- Moving off RevenueCat? Connect [Apple](/analytics/revenue/connect-apple) or
[Google Play](/analytics/revenue/connect-google-play) and leave RevenueCat
connected. The native connection takes over.
- Adding a processor FounderHQ has no connection for? Send it through the
[generic revenue API](/analytics/revenue/generic-revenue-api) and give each payment
a stable transaction ID.
# Conformance suite (https://www.getfounderhq.com/docs/analytics/protocol-reference/conformance)
Five SDKs capture the same events. If one of them counted sessions
differently, your numbers would change when you added a platform. The
conformance suite exists to stop that.
## What it is
One file, `packages/events-core/fixtures/conformance-v2.json`, holds 29
golden fixtures. Each fixture lists a sequence of actions and the exact
bytes an SDK must produce. The web, React Native, Swift, Kotlin, and Node
runners replay those actions and compare their output to the fixture,
byte for byte.
Clock, UUID, storage, transport, and device facts are all injected, so
the comparison is deterministic. A run either matches or fails.
The fixtures cover the behaviour that breaks quietly when SDKs drift:
- Session rollover on idle time and on maximum age
- Identify, reset, and set-once ordering
- Retry after a partial acknowledgement
- Identity rotation directives
- Oversize payloads
- Persistence across a restart, and offline recovery
- Remote-config re-arm on web and on mobile
- Opt-out and opt-in, and the cookieless consent transition
- Single-page-app and hard-unload page views
- Mobile screens, pixel dimensions, and app lifecycle
- Checkout metadata and account lifecycle
- Mobile purchase claims from the App Store, Google Play, and RevenueCat
## What it buys you
Your dashboard reads one number for "sessions" whether the visit came
from a browser or an iPhone. You can add Android in month six and trust
that it did not move month five's chart. When you report a bug, the fix
lands as a fixture, so it cannot come back.
## Capability matrix
Some behaviour does not exist on some platforms. A browser has no app
lifecycle. A phone has no window. Rather than skip those fixtures
quietly, the suite declares which capability each platform has, and
records a written reason for every gap.
| Capability | Web | Node | React Native | iOS | Android |
| --- | --- | --- | --- | --- | --- |
| capture | yes | yes | yes | yes | yes |
| identify | yes | — | yes | yes | yes |
| person properties | yes | — | yes | yes | yes |
| super properties | yes | — | yes | yes | yes |
| screen | yes | — | yes | yes | yes |
| screen ids | — | — | yes | yes | yes |
| sessions | yes | — | yes | yes | yes |
| persistence | yes | — | yes | yes | yes |
| offline queue | yes | — | yes | yes | yes |
| partial ack | yes | — | yes | yes | yes |
| identity rotation | yes | — | yes | yes | yes |
| remote config | yes | — | yes | yes | yes |
| consent | yes | — | yes | yes | yes |
| page views | yes | — | — | — | — |
| pageleave resilience | yes | — | — | — | — |
| window ids | yes | — | — | — | — |
| DOM autocapture | yes | — | — | — | — |
| scroll | yes | — | — | — | — |
| cookieless consent | yes | — | — | — | — |
| checkout metadata | yes | yes | — | — | — |
| accounts | yes | yes | yes | yes | yes |
| mobile session restore | — | — | yes | yes | yes |
| pixel dimensions | — | — | yes | yes | yes |
| app lifecycle | — | — | yes | yes | yes |
| mobile purchase claims | — | yes | yes | yes | yes |
| secret key | — | yes | — | — | — |
A dash means the platform declares the capability out of scope, with a
reason in the fixture file. For example, iOS records named screens
instead of browser page views, and never inspects native view trees.
Run your client against the same fixture file. If it matches, your
numbers line up with every official SDK. Start from
[Wire protocol](/analytics/protocol-reference/envelope).
# Wire protocol (https://www.getfounderhq.com/docs/analytics/protocol-reference/envelope)
This page describes the exact bytes an SDK sends and gets back. Read it
when you write your own client, debug a payload, or point an AI agent at
the contract.
The web, React Native, iOS, Android, and Node SDKs already speak this
protocol. Start at [Getting started](/analytics/getting-started) unless you
are building a client yourself.
## Send the batch
Send one `POST` to `/i/v2/e` on your FounderHQ host. Put the publishable
key in the `Authorization` header.
```http
POST https://i.getfounderhq.com/i/v2/e
Authorization: Bearer fhq_pk_XXXX
Content-Type: application/json
```
The body is an envelope with a send time and a list of events.
```json
{
"sent_at": "2026-05-23T09:41:02.115Z",
"batch": [
{
"uuid": "0197f6b8-2c31-7a4e-9f0d-5b2c1d8e4a77",
"event": "trial.started",
"distinct_id": "user_42",
"timestamp": "2026-05-23T09:41:01.980Z",
"properties": { "plan": "pro", "$pathname": "/pricing" },
"session_id": "0197f6b8-1a02-7c11-8f31-6d0b9e2a4c58",
"options": { "process_person_profile": false }
}
]
}
```
A batch holds 1 to 100 events. You may gzip the body: set
`Content-Encoding: gzip`, or add `?compression=gzip-js` to the URL.
## Envelope fields
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `sent_at` | ISO 8601 date-time | yes | When the client sent the request. FounderHQ uses it to correct clock drift. |
| `batch` | array | yes | 1 to 100 events. |
## Event fields
Normal and cookieless events use the same fields.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `uuid` | UUID string | yes | Your idempotency key. Send the same UUID twice and FounderHQ counts it once. |
| `event` | string | yes | The event name. Max 200 characters. |
| `distinct_id` | string | yes | Who did it. 1 to 400 characters. |
| `timestamp` | ISO 8601 date-time | yes | When it happened on the device. |
| `properties` | object | yes | Everything else about the event. There is no separate `context` object. |
| `session_id` | string | no | The session the event belongs to. |
| `window_id` | string | no | The browser tab or window. |
| `options` | object | yes | Capture and profile-processing hints. See below. |
`$pageview_id` belongs in `properties`, not at the event's top level.
### Options
| Field | Values | Meaning |
| --- | --- | --- |
| `process_person_profile` | boolean | Whether this event may update the contact profile. Required. |
| `cookieless_mode` | boolean | `true` marks the strict cookieless event shape. Omit it for normal events. |
### Event names
Names that do not start with `$` are yours. Use any name up to 200
characters.
Names that start with `$` are reserved. FounderHQ rejects any `$` name
that is not on the allowlist. See
[Event taxonomy](/analytics/protocol-reference/event-taxonomy).
### Distinct IDs FounderHQ refuses
A distinct ID must identify one person. FounderHQ drops these values,
whatever their letter case or surrounding spaces:
`""`, `anonymous`, `guest`, `id`, `undefined`, `null`, `none`, `nil`,
`nan`, `[object object]`, `"undefined"`, `"null"`
Send a real ID, or send your own anonymous ID. Never send the literal
string `anonymous`.
## Read the response
An accepted request returns HTTP `202` with one result per event.
```json
{
"results": [
{ "uuid": "0197f6b8-2c31-7a4e-9f0d-5b2c1d8e4a77", "status": "ok" }
]
}
```
Results come back per UUID, not per batch. One bad event never sinks the
other 99.
| Status | What it means | What your client does |
| --- | --- | --- |
| `ok` | FounderHQ stored the event. | Drop it from the queue. |
| `warning` | FounderHQ accepted the event, but changed something. The `message` says what. A duplicate UUID and a corrected timestamp both report `warning`. | Drop it from the queue. |
| `drop` | FounderHQ refused the event and will always refuse it. Common causes: an unknown `$` name, an illegal distinct ID, properties over the size limit. | Drop it. Retrying wastes calls. |
| `retry` | Something transient failed. | Keep the event queued and send it again. |
Every result may carry a `message`. Log it — it names the exact reason.
### Request-level failures
| HTTP | Meaning |
| --- | --- |
| `400` | The envelope is not valid JSON, does not match the shape, or mixes consent states. |
| `401` | The key is missing, wrong, or not a publishable key. |
| `403` | The key cannot capture events, is not scoped to a brand, or the request origin is not allowed. |
| `413` | The body is too large. Send a smaller batch. |
| `429` | Your organization is over its ingest budget. Read `Retry-After` and wait. |
| `503` | FounderHQ could not check the budget. Retry with backoff. |
## Size ceilings and rate limits
Both ingest paths — `/i/v2/e` for publishable keys and `/api/events` for
secret keys — share these ceilings.
| Limit | Value | Over it |
| --- | --- | --- |
| Events per batch | 100 | `400` |
| Request body on the wire | 256 KiB | `413` |
| Body after gzip decompression | 1 MiB | `413` |
Budgets are per organization. Every brand, every key, every site, and every
installation spends from the same allowance. Creating another key does not
buy more capacity.
| Budget | Sustained | Burst |
| --- | --- | --- |
| Requests | 200 per second | 400 |
| Events | 500 per second | 2,000 |
| Decoded body bytes | 2 MiB per second | 8 MiB |
When a limit applies to your organization, FounderHQ answers `429` with a
`Retry-After` header in whole seconds, before it reads or stores anything.
Nothing in that batch was accepted. Keep the events queued, wait the stated
delay, and send the same UUIDs again.
`Retry-After` and `Server-Timing` are exposed to browser clients, so a
web SDK can read them across origins.
## Identity directives
Sometimes FounderHQ decides your anonymous visitor must start a new
anonymous identity. That happens when the identity you sent already
belongs to somebody else. FounderHQ says so in the response:
```json
{
"results": [
{
"uuid": "0197f6b8-2c31-7a4e-9f0d-5b2c1d8e4a77",
"status": "warning",
"message": "Anonymous identity rotated; retry identify"
}
],
"directives": [{ "type": "rotate_distinct_id" }]
}
```
Your client must do two things:
1. Replace the stored anonymous ID. Use the `distinct_id` in the
directive when it is present. Otherwise generate a fresh UUID.
2. Send the `$identify` event again with a new `uuid`, and set
`$anon_distinct_id` and `$device_id` in its properties to the new
anonymous ID.
The official SDKs do this for you. Skip the step and the person stays
split across two identities.
## Normal and cookieless events
FounderHQ supports cookieless measurement for visitors who have not granted
tracking. A cookieless event uses the normal event shape with a fixed
non-person identity and can never touch a contact profile.
```json
{
"uuid": "0197f6b8-4d92-7b30-a1c7-e2f4a90b6d13",
"event": "$pageview",
"distinct_id": "$founderhq_cookieless",
"timestamp": "2026-05-23T09:41:01.000Z",
"properties": {
"$pathname": "/pricing",
"$referring_domain": "news.ycombinator.com",
"utm_source": "hn",
"$platform": "web"
},
"options": {
"cookieless_mode": true,
"process_person_profile": false
}
}
```
Differences from a granted event:
| | Normal | Cookieless |
| --- | --- | --- |
| `consent_state` | not used | not used |
| `distinct_id` | visitor or contact ID | `"$founderhq_cookieless"` |
| `event` | any allowed name | `$pageview` or `$pageleave` only |
| `properties` | any | a fixed allowlist only |
| `session_id`, `window_id`, `$pageview_id` | allowed | not allowed |
| `options` | `process_person_profile` | also requires `cookieless_mode: true` and `process_person_profile: false` |
The allowed cookieless properties are listed on
[Event taxonomy](/analytics/protocol-reference/event-taxonomy). Two of them
are coarsened on purpose: FounderHQ rounds `$page_duration_ms` to whole
seconds and `$max_scroll_percentage` to steps of 5.
Normal and cookieless events may share one batch. FounderHQ validates, routes,
and acknowledges every event independently by UUID.
## What FounderHQ does not accept
- There is no `context` object. Protocol v2 has one property namespace.
- The older `/api/ingest/batch` contract does not work. Use `/i/v2/e`.
- The publishable-key path cannot write `$revenue` or
`$account_membership`. Those need the revenue API and a secret key.
# Event taxonomy (https://www.getfounderhq.com/docs/analytics/protocol-reference/event-taxonomy)
FounderHQ owns every name that starts with `$`. Your own event and
property names never start with `$`, so the two sets can never collide.
Every table below is generated from `@founderhq/events-core`. They match
the shipped SDKs exactly.
## Reserved event names
These are the only `$` event names FounderHQ accepts. Send any other `$`
name and the event comes back with a `drop` result.
| Name |
| --- |
| `$identify` |
| `$groupidentify` |
| `$set` |
| `$pageview` |
| `$pageleave` |
| `$session_start` |
| `$screen` |
| `$autocapture` |
| `$rageclick` |
| `$dead_click` |
| `$outbound_click` |
| `$web_vitals` |
| `$application_installed` |
| `$application_updated` |
| `$application_opened` |
| `$application_backgrounded` |
| `$push_notification_opened` |
Your own events use plain names: `trial.started`, `invite_sent`,
`Report Exported`. Pick one convention and keep it.
## Event properties
The SDKs fill these in for you. Set them yourself only when you send
events from your own client.
| Name |
| --- |
| `$display_name` |
| `$current_url` |
| `$pathname` |
| `$host` |
| `$referrer` |
| `$referring_domain` |
| `$search_engine` |
| `$browser` |
| `$browser_version` |
| `$browser_webdriver` |
| `$os` |
| `$os_version` |
| `$device_type` |
| `$device_id` |
| `$device_manufacturer` |
| `$device_name` |
| `$device_model` |
| `$platform` |
| `$session_id` |
| `$window_id` |
| `$pageview_id` |
| `$screen_id` |
| `$viewport_width` |
| `$viewport_height` |
| `$screen_width` |
| `$screen_height` |
| `$screen_name` |
| `$lib` |
| `$lib_version` |
| `$timezone` |
| `$locale` |
| `$browser_language` |
| `$app_build` |
| `$app_name` |
| `$app_namespace` |
| `$app_version` |
| `$deep_link_url` |
| `$max_scroll_percentage` |
| `$last_scroll_percentage` |
| `$page_duration_ms` |
| `$active_duration_ms` |
| `$leave_reason` |
| `$outbound_url` |
| `$outbound_domain` |
| `$prev_pageview_id` |
| `$prev_pageview_max_scroll_percentage` |
| `$prev_pageview_duration_ms` |
| `$prev_pageview_pathname` |
| `$geoip_country_code` |
| `$geoip_country_name` |
| `$geoip_city_name` |
| `$geoip_continent_code` |
| `$geoip_continent_name` |
| `$geoip_time_zone` |
| `$geoip_subdivision_1_code` |
| `$geoip_subdivision_1_name` |
| `$ip_hash` |
| `$ip_truncated` |
| `$ip` |
| `$attribution_channel` |
| `$attribution_source` |
| `$purchase_attribution_token` |
| `$groups` |
| `$group_set` |
| `$account_context_token` |
| `$account_span_id` |
| `$bot` |
| `$bot_category` |
## Campaign properties
Campaign keys stay unprefixed, because they arrive unprefixed in the URL.
FounderHQ reads all 24 from the landing URL and uses them to answer
"which channel introduced this contact".
| Name |
| --- |
| `utm_source` |
| `utm_medium` |
| `utm_campaign` |
| `utm_term` |
| `utm_content` |
| `gclid` |
| `gad_source` |
| `gclsrc` |
| `dclid` |
| `gbraid` |
| `wbraid` |
| `fbclid` |
| `msclkid` |
| `twclid` |
| `li_fat_id` |
| `mc_cid` |
| `igshid` |
| `ttclid` |
| `rdt_cid` |
| `epik` |
| `qclid` |
| `sccid` |
| `irclid` |
| `_kx` |
## Session properties
FounderHQ derives these and attaches them to the session, not to
individual events. You do not send them.
| Name |
| --- |
| `$entry_current_url` |
| `$entry_pathname` |
| `$entry_utm_source` |
| `$entry_utm_medium` |
| `$entry_utm_campaign` |
| `$entry_utm_term` |
| `$entry_utm_content` |
| `$entry_referring_domain` |
| `$channel_type` |
| `$session_duration` |
| `$is_bounce` |
| `$pageview_count` |
| `$end_pathname` |
## Cookieless measurement allowlist
When a visitor has not granted tracking, the SDK sends the small cookieless
event described in [Wire protocol](/analytics/protocol-reference/envelope).
Only the names below are allowed. FounderHQ removes anything else on the
client and rejects anything extra at ingest.
## Cookieless event names
| Name |
| --- |
| `$pageview` |
| `$pageleave` |
## Cookieless base properties
| Name |
| --- |
| `$pathname` |
| `$referring_domain` |
| `utm_source` |
| `utm_medium` |
| `utm_campaign` |
| `utm_term` |
| `utm_content` |
| `$platform` |
## Cookieless page-leave properties
| Name |
| --- |
| `$page_duration_ms` |
| `$max_scroll_percentage` |
| `$leave_reason` |
The page-leave properties are valid on `$pageleave` only. Put any of them
on a `$pageview` and the event is refused.
# Protocol reference (https://www.getfounderhq.com/docs/analytics/protocol-reference)
Every FounderHQ SDK speaks one capture protocol. This section documents
it, so you can build your own client, debug a payload, or hand the
contract to an AI agent.
If you install an SDK, the protocol is already handled. Go to
[Getting started](/analytics/getting-started) instead.
## Pages
| Page | Read it to |
| --- | --- |
| [Wire protocol](/analytics/protocol-reference/envelope) | See the exact request, the exact response, and what each result status means. |
| [Event taxonomy](/analytics/protocol-reference/event-taxonomy) | Look up the reserved `$` names FounderHQ owns, so your own names never collide. |
| [JSON Schema](/analytics/protocol-reference/json-schema) | Download the schema and validate a payload before you send it. |
| [Conformance suite](/analytics/protocol-reference/conformance) | Understand why all five SDKs produce the same numbers. |
## The short version
- One endpoint: `POST /i/v2/e`.
- One credential: your publishable key, as `Authorization: Bearer`.
- One body shape: `{ "sent_at": "...", "batch": [...] }`, up to 100 events.
- One result per event UUID: `ok`, `warning`, `drop`, or `retry`.
- One property namespace. There is no `context` object.
Server-side capture is a different surface. It uses a secret key and the
[API reference](/analytics/api-reference) endpoints.
# JSON Schema (https://www.getfounderhq.com/docs/analytics/protocol-reference/json-schema)
The Events v2 batch has a published JSON Schema. Point a validator at it
and you find a malformed payload on your own machine, before you spend a
request finding it in production.
**Download:** [`/protocol/events-v2.schema.json`](/protocol/events-v2.schema.json)
It is draft 2020-12. Its `$id` is
`https://www.getfounderhq.com/schemas/events-v2.json`.
## What the schema covers
| Rule | Enforced |
| --- | --- |
| `sent_at` and `batch` are required, and nothing else is allowed at the top level | yes |
| A batch holds 1 to 100 events | yes |
| Every event requires `uuid`, `event`, `distinct_id`, `timestamp`, `properties`, and `options` | yes |
| Cookieless events require `distinct_id: "$founderhq_cookieless"` and both cookieless options | yes |
| Cookieless properties match the allowlist, with their length and range limits | yes |
| A cookieless `$pageview` cannot carry page-leave properties | yes |
Two rules live in the API, not the schema, because they need context:
- `$` event names must be on the reserved allowlist.
- Key permissions and the server-side cookieless capability gate are enforced
per event.
## Validate your payload
Add [ajv](https://ajv.js.org), then check a batch before you send it.
```js
import Ajv from "ajv";
import addFormats from "ajv-formats";
const schema = await fetch("https://www.getfounderhq.com/docs/protocol/events-v2.schema.json").then((r) => r.json());
const validate = addFormats(new Ajv({ strict: false })).compile(schema);
if (!validate(batch)) console.error(validate.errors);
```
ajv is the only thing you add. The schema itself has no dependencies and
no remote references, so you can vendor the file into your repository and
validate offline.
The schema proves the shape is right. FounderHQ still checks your key,
your event names, and your distinct IDs at ingest. Read
[Wire protocol](/analytics/protocol-reference/envelope) for the per-event
result codes.
# Capture Journey events (https://www.getfounderhq.com/docs/journeys/api-reference/captureJourney)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Complete a Journey (https://www.getfounderhq.com/docs/journeys/api-reference/completeJourney)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Get the published Journey configuration (https://www.getfounderhq.com/docs/journeys/api-reference/getPublishedJourney)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Prepare a Journey (https://www.getfounderhq.com/docs/journeys/api-reference/prepareJourney)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Validate a Journey (https://www.getfounderhq.com/docs/journeys/api-reference/validateJourney)
{/* GENERATED from apps\/docs\/openapi\/founderhq-api.json — do not edit. Regenerate with: pnpm --filter docs generate */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
>
);
}
# Stripe Checkout API (https://www.getfounderhq.com/docs/analytics/revenue/connect-stripe/checkout-api)
Attribute payments from Checkout Sessions that your own server creates. This
takes about ten minutes.
[Connect Stripe](/analytics/revenue/connect-stripe) first. Install the
[web SDK](/analytics/sdks/web) on your pricing page and the
[Node SDK](/analytics/sdks/node) on your server.
## 1. Send the visitor IDs to your server
The browser holds the two IDs that identify the visitor. Read them with
`checkoutMetadata()` and post them with the rest of the checkout request.
```tsx
// app/pricing/checkout-button.tsx
"use client";
import { founderhq } from "@founderhq/events";
export function CheckoutButton({ priceId }: { priceId: string }) {
async function checkout() {
const identity = founderhq.checkoutMetadata();
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ priceId, identity }),
});
const { url } = await response.json();
window.location.href = url;
}
return ;
}
```
## 2. Place the metadata on the session and on the renewal object
Put the same metadata in two places. Session metadata alone does not survive
renewals, so the second placement is what keeps a subscription attributed.
```ts
// app/api/checkout/route.ts
import Stripe from "stripe";
import { checkoutMetadata } from "@founderhq/events-node";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { priceId, identity } = await request.json();
const attribution = checkoutMetadata({
anonymousId: identity.fhq_anonymous_id,
sessionId: identity.fhq_session_id,
});
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "https://acme.com/thanks",
cancel_url: "https://acme.com/pricing",
metadata: attribution,
subscription_data: { metadata: attribution },
});
return Response.json({ url: session.url });
}
```
Selling a one-time product? Use `payment_intent_data: { metadata: attribution }`
in place of `subscription_data`.
`checkoutMetadata()` throws when either ID is empty. Catch that and create the
session without the metadata rather than blocking the sale.
## 3. Let renewals resolve themselves
FounderHQ binds the Stripe customer and subscription on the first payment.
Later renewals arrive with no browser session, and FounderHQ resolves them
through that binding.
## Verify
1. Start a checkout on your pricing page.
2. In Stripe, open the Checkout Session. The metadata shows
`fhq_anonymous_id` and `fhq_session_id`.
3. Open the created subscription. It shows the same two keys.
4. Pay with a test card. In FounderHQ, the payment shows the channel that
brought the buyer.
## Troubleshoot
### The session has metadata but the subscription does not
You set `metadata` only. Add `subscription_data.metadata` with the same object
and create a new session.
### The payment arrives with no channel
The browser sent empty IDs. `checkoutMetadata()` in the browser returns an
empty object when the visitor denied consent. Check consent before you blame
the server.
# Connect Stripe (https://www.getfounderhq.com/docs/analytics/revenue/connect-stripe)
Connect Stripe so FounderHQ records every payment, refund, dispute, and
subscription change. The recommended path is a read-only key — there is
nothing to configure in Stripe.
You need a [FounderHQ brand](/analytics/getting-started) with the Events SDK
installed on your site, and a Stripe account you can open the dashboard for.
## Which should I choose?
| Mode | Choose it when | Your revenue appears |
| ------------------------- | ------------------------------------------ | -------------------- |
| **Paste a read-only key** | You want the simplest setup | Within a few minutes |
| **Webhook** | You want to configure every event yourself | In seconds |
| **Connect with Stripe** | Shown when one-click connect is available | In seconds |
## Paste a read-only key (recommended)
1. In FounderHQ, open **Settings → Integrations → Revenue**.
2. Choose Stripe and the brand. Select **Paste a read-only
key**.
3. Open [Stripe API keys](https://dashboard.stripe.com/apikeys). Create a
restricted key with **Read** access to **Account** and **Events**. Leave
every other permission at **None**.
4. Copy the `rk_` value into FounderHQ and connect.
There is no webhook to configure in Stripe. Your revenue appears within a few
minutes. When you first connect, FounderHQ can see about the last 30 days.
FounderHQ encrypts the key. You can revoke it from Stripe anytime. FounderHQ
refuses unrestricted `sk_` keys.
## Webhook (manual)
### 1. Create the connection in FounderHQ
In FounderHQ, open **Settings → Integrations → Revenue**. Choose Stripe, the
brand. Select **Webhook**.
Enter your Stripe account ID. It starts with `acct_` and Stripe shows it in
your dashboard. FounderHQ rejects events from any other account.
FounderHQ then gives you a webhook URL:
```text
https://app.getfounderhq.com/api/hooks/payments/stripe/CONNECTION_ID
```
### 2. Add the endpoint in Stripe
In Stripe, open Developers and add a webhook endpoint with that URL.
Set the endpoint API version to `2025-05-28.basil` or later. FounderHQ refuses
older versions, because the `invoice_payment.paid` event does not exist before
that version.
Keep the environment matched. A live endpoint must belong to a live FounderHQ
connection, and a test endpoint to a test connection.
### 3. Select the events FounderHQ reads
```text
checkout.session.completed
payment_intent.succeeded
invoice.created
invoice.sent
invoice.upcoming
invoice.updated
invoice.finalized
invoice.finalization_failed
invoice.paid
invoice.payment_succeeded
invoice.payment_failed
invoice.payment_action_required
invoice.overdue
invoice.overpaid
invoice.marked_uncollectible
invoice.voided
invoice.will_be_due
invoice.deleted
invoice_payment.paid
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
refund.created
refund.updated
refund.failed
charge.refunded
charge.dispute.created
charge.dispute.closed
credit_note.created
credit_note.updated
credit_note.voided
charge.succeeded
```
FounderHQ ignores any other event type. `charge.succeeded` matters only if you
still create legacy Charges.
### 4. Paste the signing secret back
Copy the endpoint's signing secret from Stripe. It starts with `whsec_`. Paste
it into the FounderHQ connection.
### 5. Send a test event
Send any selected event from Stripe. FounderHQ verifies the signature, the API
version, the environment, and the account ID.
### 6. Attach attribution to your checkout
A connected webhook tells FounderHQ that money moved. It does not tell
FounderHQ who to thank. Pick the one page that matches how you charge:
| How you charge | Page |
| --------------------------------------------- | -------------------------------------------------------------------------------- |
| Stripe-hosted Payment Links or Pricing Tables | [Stripe Payment Links](/analytics/revenue/connect-stripe/payment-links) |
| Checkout Sessions you create on your server | [Stripe Checkout API](/analytics/revenue/connect-stripe/checkout-api) |
| Your own payment form on PaymentIntents | [Stripe PaymentIntent API](/analytics/revenue/connect-stripe/payment-intent-api) |
### Verify
After the first verified event, the connection shows **Connected** in
FounderHQ. Stripe shows a `200` response for the delivery.
### Troubleshoot
#### Stripe shows "This Stripe endpoint uses an older API version."
The endpoint sends an API version before `2025-05-28.basil`. Edit the endpoint
in Stripe and raise the version.
#### Stripe shows "Invalid signature"
The pasted secret is wrong, or you rolled the secret in Stripe. Copy the
current signing secret and paste it again.
#### Stripe shows "This endpoint only accepts live events."
A test endpoint points at a live connection, or the reverse. Create the
connection in the environment that matches the endpoint.
#### Stripe shows "This event belongs to another Stripe account."
The account ID on the connection does not match the account that sent the
event. Fix the `acct_` value.
## Connect with Stripe (one click)
When your Payments settings show a **Connect with Stripe** option, it is the
fastest path: click it, approve access on Stripe's page, and your revenue
appears in seconds. You can revoke FounderHQ from your Stripe dashboard
anytime. If you don't see this option, use a read-only key above — the data
FounderHQ records is identical.
# Stripe PaymentIntent API (https://www.getfounderhq.com/docs/analytics/revenue/connect-stripe/payment-intent-api)
Attribute payments from your own payment form, built on PaymentIntents. This
takes about ten minutes.
[Connect Stripe](/analytics/revenue/connect-stripe) first. Install the
[web SDK](/analytics/sdks/web) on your checkout page and the
[Node SDK](/analytics/sdks/node) on your server.
FounderHQ treats the PaymentIntent as the transaction. Put the attribution
metadata on the PaymentIntent itself, not on a charge or an invoice.
## 1. Send the visitor IDs to your server
```tsx
// app/checkout/pay-button.tsx
"use client";
import { founderhq } from "@founderhq/events";
export function PayButton({ amountMinor }: { amountMinor: number }) {
async function startPayment() {
const identity = founderhq.checkoutMetadata();
const response = await fetch("/api/payment-intent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ amountMinor, identity }),
});
const { clientSecret } = await response.json();
// Hand clientSecret to Stripe Elements as you already do.
console.log(clientSecret);
}
return ;
}
```
## 2. Create the PaymentIntent with the metadata
```ts
// app/api/payment-intent/route.ts
import Stripe from "stripe";
import { checkoutMetadata } from "@founderhq/events-node";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { amountMinor, identity } = await request.json();
const attribution = checkoutMetadata({
anonymousId: identity.fhq_anonymous_id,
sessionId: identity.fhq_session_id,
});
const intent = await stripe.paymentIntents.create({
amount: amountMinor,
currency: "usd",
automatic_payment_methods: { enabled: true },
metadata: attribution,
});
return Response.json({ clientSecret: intent.client_secret });
}
```
Amounts are in minor units. `4900` is $49.00.
## 3. Know what a refund does
FounderHQ subtracts a refund only when the individual Refund object reaches
`succeeded`. Partial refunds each subtract their own amount. A pending refund
changes nothing.
## Verify
1. Start a payment on your checkout page.
2. In Stripe, open the PaymentIntent. Its metadata shows `fhq_anonymous_id`
and `fhq_session_id`.
3. Complete the payment with a test card.
4. In FounderHQ, open Revenue. The payment shows the channel that brought the
buyer.
## Troubleshoot
### The metadata sits on the charge, not the PaymentIntent
FounderHQ reads the PaymentIntent. Move the metadata to the
`paymentIntents.create` call.
### The payment arrives with no channel
The browser sent empty IDs, or this buyer never loaded a page that runs the
SDK. Check that the checkout page itself loads the web SDK.
# Stripe Payment Links (https://www.getfounderhq.com/docs/analytics/revenue/connect-stripe/payment-links)
Attribute payments that people make on a Stripe-hosted Payment Link. This takes
about five minutes.
[Connect Stripe](/analytics/revenue/connect-stripe) first, and install the
[web SDK](/analytics/sdks/web) on the page that holds your buy button.
A hosted Payment Link runs on Stripe's domain, so you cannot put FounderHQ IDs
in the checkout code. You pass one opaque token instead. FounderHQ mints the
token on its own server, so the attribution survives even if the buyer never
comes back to your site.
## 1. Ask the SDK for a token
`paymentLinkToken()` returns a string that starts with `fhqref_`. The token is
valid for 30 days.
It returns `null` when the visitor has not granted consent, has opted out, or
when the SDK has no publishable key. Handle that case by sending the buyer to
the plain link.
## 2. Put the token on the link as `client_reference_id`
```tsx
// app/pricing/buy-button.tsx
"use client";
import { founderhq } from "@founderhq/events";
const PAYMENT_LINK = "https://buy.stripe.com/XXXX";
export function BuyButton() {
async function checkout() {
const token = await founderhq.paymentLinkToken();
const url = new URL(PAYMENT_LINK);
if (token) url.searchParams.set("client_reference_id", token);
window.location.href = url.toString();
}
return ;
}
```
Using a Stripe Pricing Table instead? Put the same token in the table's
`client-reference-id` attribute. FounderHQ reads both from the same field on
the completed Checkout Session.
## 3. Let the token do the rest
When the buyer pays, Stripe sends `checkout.session.completed` with your token.
FounderHQ exchanges the token for the visitor and the session that started the
purchase, then attributes the payment.
Renewals carry no token. FounderHQ binds the Stripe customer and subscription
on the first payment, so later renewals resolve through that binding.
## Verify
1. Open your pricing page in a browser and click the buy button.
2. Confirm the Stripe URL now carries `client_reference_id=fhqref_...`.
3. Pay with a Stripe test card on a test connection.
4. In FounderHQ, open Revenue. The payment shows the channel that brought the
buyer, under Introduced by and Closed by.
## Troubleshoot
### The URL has no `client_reference_id`
`paymentLinkToken()` returned `null`. The visitor denied consent or opted out,
or the page origin is not in your publishable key's allowed origins. Add the
origin to the key.
### The payment arrives with no channel
The buyer opened the link from somewhere that never loaded your SDK, for
example an email link straight to Stripe. Send buyers through a page you own.