# 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.