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.
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, andoptions.process_person_profilesurvive. Unknown envelope and options keys (includingcookieless_mode) are discarded by the typedCodableenvelope. - 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. uuidmust have the RFC 4122 8-4-4-4-12 hex form, version 1–8 and variant 8/9/a/b.distinct_idis trimmed, must contain 1–400 characters, and must pass the SDK's illegal-identity check. Invalid values drop the event.timestampmust be a real RFC 3339 calendar instant with seconds, optional fractional seconds, andZor a±HH:MMoffset. Whole seconds are valid; impossible dates such as 30 February and values without a zone are dropped.propertiesmust be a JSON object with JSON-encodable values, andoptions.process_person_profilemust be a boolean. Swift's typed event enforces the object and boolean types; decoding invalid types throws.- An invalid or non-v7
session_idis removed while keeping the event. Optionalwindow_idis 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 samesetAccount 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.
Consent
| You call | What 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.
| 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.
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)
}| Method | What it does |
|---|---|
registerPushToken(_:options:) with a Data token | Stores the APNs device token. options defaults to .apns() |
registerPushToken(_:options:) with a String token | Stores 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.
| 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 |
capturePushNotificationOpened | Bool | true | Records when someone taps one of your notifications |
onPushNotificationOpened | FounderHQPushOpenedHandler? | nil | Gives your app the message ID and link of an opened notification, on the main actor |
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 |
autoProperties | FounderHQAutoProperties? | nil | Adds properties to screens, taps, and rage taps on the main actor |
beforeSend | FounderHQBeforeSend? | nil | Changes or drops finished events before queueing, except purchase and push device controls |
debug | Bool | false | Logs 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
| Method | Returns | What it does |
|---|---|---|
await flush() | Bool | Sends queued events now, and tells you whether the queue drained |
await close() | — | Flushes, stops the timer, and removes the lifecycle observers |
await getDistinctId() | String | The current identity |
await 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.
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.
| 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 for the wire format, the reserved event names, and the campaign properties a deep link can carry.