Documentation
SDKs

iOS

Use the FounderHQ iOS SDK.

Capture screens, app lifecycle, and your own events from a Swift app.

Before you start

You need a publishable key (fhq_pk_...) for the brand you want to measure. See Getting started.

Nothing is sent anywhere. A publishable key is saved in this browser. A secret key is kept only until you reload.

Install

In Xcode, add https://github.com/FounderHQ/founderhq-events-ios with Swift Package Manager and select version 1.3.0.

For CocoaPods:

pod 'FounderHQEvents', '~> 1.3.0'

You can also install directly from the Git release:

pod 'FounderHQEvents', :git => 'https://github.com/FounderHQ/founderhq-events-ios.git', :tag => 'v1.3.0'

The SDK needs iOS 15 or macOS 12. Swift Package Manager needs Swift 5.9.

Initialize

Create one client and keep it for the life of the app.

import FounderHQEvents

let events = FounderHQEvents(apiKey: "fhq_pk_XXXX")

Pass a configuration when you want to change the defaults:

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

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:

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:

PricingView()
    .founderHQScreen("Pricing", client: events)

Call events.screen("Pricing") directly anywhere else.

Screen events carry $screen_name, $screen_id, and $prefers_color_scheme ("dark" or "light"). The SDK reads it from the trait collection of the view controller that appeared last, so a window's overrideUserInterfaceStyle counts. Until a view controller has appeared, and when the appearance is unspecified, the property is omitted. This also works in app extensions. Other automatic events do not get it.

Adding properties to automatic events

Use autoProperties to add your own properties to $screen, $autocapture, and $rageclick:

let events = FounderHQEvents(
    apiKey: "fhq_pk_XXXX",
    configuration: .init(
        captureElementInteractions: true,
        autoProperties: { context in
            ["section": context.screenName == "Pricing" ? "sales" : "app"]
        }
    )
)

Screen context tracks every screen() call and automatic screen observation, even when screen events are disabled or consent is off. Tracking does not emit events or call autoProperties while capture is disabled.

The callback runs on the main actor. FounderHQAutoPropertiesContext gives it event, the current screenName (or nil), element, and elementDescription. For a tap, element is the actual UIKit object, a UIView that reports the gesture/control action or the UIBarButtonItem that reports the action (not necessarily the deepest visible child). elementDescription is the same JSONObject recorded in $elements[0]. Screens have neither element field. The callback runs for automatic UIKit screens, founderHQScreen, explicit screen calls, and each captured tap or rage tap. Return a [String: Any] map or nil. If it throws, capture continues without its properties and the SDK logs one warning per client, even with debug off.

Lifecycle, session, identity, push, purchase, and custom capture() events do not call it. Calling capture("$screen") yourself does not call it either. iOS does not capture $dead_click.

You can also attach properties to a UIKit view:

productCard.fhqProperties = ["product_id": "sku_1"]
buyButton.fhqProperties = ["action": "buy"]
productCard.addSubview(buyButton)

A tap on the button carries both properties at the top level. The SDK walks from the control or gesture view that reports the tap through all ancestors, including those beyond the five described in $elements. For a bar button item it starts at its customView, if present. The nearest valid value wins a clash. Set fhqProperties = nil to remove properties. Keys are used as given; snake_case is the convention.

SwiftUI apps can add tap properties through autoProperties for now:

let configuration = FounderHQEventsConfiguration(
    captureElementInteractions: true,
    autoProperties: { context in
        guard context.event == "$autocapture", context.screenName == "Shop" else { return nil }
        if context.elementDescription?["accessibility_identifier"] == .string("buy") {
            return ["action": "buy"]
        }
        return ["section": "shop"]
    }
)

Give the screen a name with founderHQScreen("Shop"). Element descriptions reflect the UIKit object reporting the tap. A declarative SwiftUI form is planned. Text inputs remain excluded from automatic tap capture.

Later values win in this order: automatic properties → autoProperties → fhqProperties → explicit screen(name, properties:) values. Explicit screen properties cannot replace the SDK’s $screen_name or $screen_id. Both new sources share these limits:

  • Keys must contain 1–64 UTF-16 code units and must not start with $.
  • Values must be strings, finite numbers, or booleans. Other values are dropped.
  • Strings are trimmed and cut to 200 UTF-16 code units without splitting a surrogate pair.
  • Each event keeps up to 20 extra keys. Nearest views come first, then the callback. Keys within each map are sorted, since Swift dictionaries have no insertion order. The first valid value wins.

Callback and declarative strings are sent as given apart from the trimming and length limit above; they are not scrubbed for card numbers, SSNs, or other sensitive content. These limits do not change explicit screen() or capture() properties. Remote config updates keep your callback. autoProperties cannot rename or drop events.

beforeSend

Use beforeSend to change or drop a finished event before it enters the queue. Purchase and push device control events bypass it:

let configuration = FounderHQEventsConfiguration(beforeSend: { event in
    guard event.event != "debug.action" else { return nil }
    var updated = event
    updated.properties.removeValue(forKey: "email")
    return updated
})

It receives a FounderHQEvent, after automatic, callback, declarative, and explicit properties have been merged. Event properties use JSONValue, for example .string("sales"), .number(2), or .bool(true). Return the event, a changed copy, or nil to drop it. A thrown error drops it too. Only hook results are validated; without a hook the SDK queues the event it built unchanged.

  • Only uuid, event, distinct_id, timestamp, properties, session_id, window_id, and options.process_person_profile survive. Unknown envelope and options keys (including cookieless_mode) are discarded by the typed Codable envelope.
  • The event name is trimmed and must be nonempty. Its original name is allowed, including internal names. A rename must pass the public event-name rules on the trimmed name; an app cannot rename an event to $mobile_purchase_claim.
  • uuid must have the RFC 4122 8-4-4-4-12 hex form, version 1–8 and variant 8/9/a/b. distinct_id is trimmed, must contain 1–400 characters, and must pass the SDK's illegal-identity check. Invalid values drop the event.
  • timestamp must be a real RFC 3339 calendar instant with seconds, optional fractional seconds, and Z or a ±HH:MM offset. Whole seconds are valid; impossible dates such as 30 February and values without a zone are dropped.
  • properties must be a JSON object with JSON-encodable values, and options.process_person_profile must be a boolean. Swift's typed event enforces the object and boolean types; decoding invalid types throws.
  • An invalid or non-v7 session_id is removed while keeping the event. Optional window_id is kept only if it is a string of at most 200 characters.

$mobile_purchase_prepared and $mobile_purchase_claim never call beforeSend: they enter the queue as built by the SDK so purchase attribution cannot silently break. Other lifecycle, session, identity, push, and custom events do call it. An $identify synthesized after an identity-rotation directive also calls it, with properties the hook may already have transformed once. Make hooks idempotent. Delivery retries of an already queued event keep the original hook result.

The closure is @Sendable and runs synchronously on the SDK's state actor, with no guarantee of a particular thread. Keep it short and use safe shared state; it is not a main-actor callback. autoProperties and the UIKit declarative-property walk run on the main actor. Changing or dropping automatic events can affect sessions, identity, and screen reports.

Identify a contact

events.identify("user_42", properties: ["email": "jane@acme.com"])

Change identity and account in one call:

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. When a push token is registered, it first removes the device from the person who signs out.

Accounts

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.

You callWhat happens
optIn()Capture is on
optOut()Capture stops
await isOptedOut()Returns true while capture is off

Set optOutByDefault: true in the configuration to start silent until the person agrees. See 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.

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

Push notifications

Register the device so FounderHQ Sequences can send it a push. The SDK never asks for the notification permission and never gets a token itself.

Create the SDK in application(_:didFinishLaunchingWithOptions:). iOS reports the tap that started the app right after that method returns. An SDK that is created later does not see that tap.

func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    events.registerPushToken(deviceToken)
}
MethodWhat it does
registerPushToken(_:options:) with a Data tokenStores the APNs device token. options defaults to .apns()
registerPushToken(_:options:) with a String tokenStores a Firebase (.fcm()) or Expo (.expo()) token, or an APNs token in hex (.apns())
setPushEnabled(_:)Your app's own push switch for this device. false stops pushes; the device stays registered
unregisterPushToken()Removes the device from the person and forgets the token
capturePushNotificationOpened(userInfo:properties:)Reports an open by hand and returns the link. Only needed when capturePushNotificationOpened is false. It sends nothing for a payload with no FounderHQ key
FounderHQPushPayload(userInfo:)Reads messageId and link from a notification payload
dismissPushNotification(key:completion:)Removes the delivered notifications with that notification key (or message ID). completion gets how many
handleRemoteNotification(userInfo:completion:)Removes a notification when FounderHQ asks from your server. Returns true for such a push, false for every other

Each of the first four has an ...AndWait form. How to use the last two is on Replace or remove a notification.

FounderHQPushRegistrationOptions holds what goes with a token, in the same order on every platform: provider, appId, environment, enabled, permission. Build it with .apns(...), .fcm(...), or .expo(...):

events.registerPushToken(deviceToken, options: .apns(environment: .sandbox, enabled: false))
events.registerPushToken(fcmToken, options: .fcm())

An app uses one push service. With APNs, pass the Data token. With Firebase, pass the FCM token with .fcm() and never the APNs token: Firebase refuses it.

The SDK always stores the token. A device is registered only for an identified person: the SDK sends the registration after identify and until reset(), and sends nothing for a guest. While a person is identified, the SDK sends the registration again on every app start, on a new token, and after setPushEnabled. It sends one registration in a start: a registerPushToken call that changes nothing sends no second one.

reset() removes the device from the person who signs out. It keeps the token on the phone and clears the setPushEnabled switch. An identify for another person clears the switch too. If the phone is offline, the SDK keeps the removal and sends it first on the next app start. If the same person signs in again before that, the SDK drops the removal. While the person is opted out, registrations wait, but a removal is still sent.

The SDK does not store or send a token that FounderHQ refuses. It logs the reason with NSLog, and never the token.

For an APNs token the SDK reads the environment from the build: sandbox for a simulator or a development profile, production for an ad hoc, TestFlight, or App Store build. When it cannot tell (a Mac build without a profile), it sends none and FounderHQ tries production, then sandbox. Pass .apns(environment:) to set it yourself.

The SDK reads the permission state from the system without a prompt and sends it with the registration. Pass permission: in the options to set it yourself.

When a person opens a notification, the SDK sends $push_notification_opened with $push_message_id. It reads two keys of the payload, fhqOutboundMessageId and fhqLink, and nothing else. The SDK never opens the link. Set onPushNotificationOpened to get it. The handler runs on the main actor, so it can open the link directly:

FounderHQEventsConfiguration(
    onPushNotificationOpened: { payload in
        if let link = payload.link, let url = URL(string: link) {
            UIApplication.shared.open(url)
        }
    }
)

A notification that Expo delivered keeps the two keys under body. The SDK reads them there.

With capturePushNotificationOpened: false, call capturePushNotificationOpened(userInfo:) from your notification delegate. It returns the link. For a payload with no FounderHQ key it sends nothing and returns nil, so you can pass every notification. While capturePushNotificationOpened is true, the SDK reports the open and this method only returns the link.

The full setup is on Push.

Configuration

FounderHQEventsConfiguration takes these values.

ValueTypeDefaultWhat it does
hostURLhttps://i.getfounderhq.comWhere events are sent
flushAtInt20Sends a batch once this many events are queued
flushIntervalTimeInterval10Sends a batch on this timer, in seconds
personProfilesFounderHQPersonProfiles.identifiedOnlyWhen contact property changes are applied
optOutByDefaultBoolfalseStarts opted out
captureLifecycleBooltrueApp opened and backgrounded events
captureScreensBooltrueUIKit screen events
captureSessionsBooltrueSession start events
captureInstallUpdatesBooltrueApp installed and updated events
remoteConfigBooltrueReads capture settings you set in FounderHQ
accountFounderHQAccountContext?nilInstalls account context before the first event
purchasePrepareTimeoutTimeInterval3How long preparePurchase waits before checkout goes ahead offline
captureElementInteractionsBoolfalseTaps and rage taps. See Element interactions
capturePushNotificationOpenedBooltrueRecords when someone taps one of your notifications
onPushNotificationOpenedFounderHQPushOpenedHandler?nilGives your app the message ID and link of an opened notification, on the main actor
tracingHeaders[String]?nilHostnames whose requests carry the session id
maxQueueSizeInt1000Events kept offline before the oldest are dropped
eventTTLTimeInterval86400How long an unsent event may wait, in seconds
maxRetriesInt5Times a failed event is retried
autoPropertiesFounderHQAutoProperties?nilAdds properties to screens, taps, and rage taps on the main actor
beforeSendFounderHQBeforeSend?nilChanges or drops finished events before queueing, except purchase and push device controls
debugBoolfalseLogs what the SDK drops or refuses

FounderHQEventsDependencies replaces the clock, UUID source, storage, transport, platform facts, screen-capture installer, and appearance provider. Use it in tests.

Sending

MethodReturnsWhat it does
await flush()BoolSends queued events now, and tells you whether the queue drained
await close()—Flushes, stops the timer, and removes the lifecycle observers
await getDistinctId()StringThe current identity
await getSessionId()StringThe 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.

StoredNot stored
The class, accessibility identifier, and accessibility labelAnything a person typed
The action, the enabled state, and the selected stateTap coordinates
The title a UIButton, UIBarButtonItem, or UISegmentedControl showsThe contents of a UITextField, UITextView, or UISearchBar

Text fields, text views, and search bars are skipped. A tap on one records nothing at all.

Captured titles have whitespace normalized, zero-width characters removed, and are truncated to 255 characters. Card-number and SSN patterns are not removed. Callback and declarative strings are not passed through this element text normalization; only their documented trimming and length limits apply.

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.

KeyWhat it turns on and off
capture_sessionsSession start events
capture_screensScreen events
capture_lifecycleApp opened and backgrounded events
autocaptureTaps and rage taps
capture_rageclicksRage 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 for the wire format, the reserved event names, and the campaign properties a deep link can carry.

AI agent or LLM? Read this page as markdown

On this page