Welcome to Encatch Docs
Mobile & Native SDKs

iOS SDK

Complete integration guide for the Encatch native iOS SDK — in-app feedback and survey collection for iOS apps

The Encatch iOS SDK lets you collect in-app feedback and surveys in native iOS apps. Display forms as a modal WebView overlay or inline in your layout, identify users, track screens and events, and submit responses to the Encatch backend.

The SDK is written in Swift, using URLSession for networking, UserDefaults for storage, and WKWebView for form rendering. It has no dependencies — nothing else to link, no embedded runtimes.


Overview

  • Package: encatch-swift (Swift Package Manager)
  • Version: 0.1.1
  • Platforms: iOS 15+, macOS 12+ via Mac Catalyst (see the separate macOS page for Catalyst specifics)
  • Repository: github.com/get-encatch/encatch-android (development happens under swift/ in the cross-platform monorepo; encatch-swift is the SPM distribution mirror, updated per release)
  • License: MIT

Installation

File → Add Package Dependencies… and enter the package URL:

https://github.com/get-encatch/encatch-swift

Set the dependency rule to Up to Next Minor Version, then add the Encatch library to your app target.

dependencies: [
    .package(url: "https://github.com/get-encatch/encatch-swift", from: "0.1.1"),
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "Encatch", package: "encatch-swift"),
        ]
    ),
]

Pre-1.0 versioning

While versions are 0.x, minor bumps may contain breaking changes. SPM's from: "0.1.1" rule only auto-updates patch releases, which is the safe default — review the release notes before moving to a new minor version.


Quick Start

1. Initialization

Install the modal form host once at app launch with EncatchFormHost.install(), then initialize the SDK. EncatchFormHost.install() is required for modal forms — it mounts the listener that presents the form overlay on the topmost view controller.

import SwiftUI
import Encatch

@main
struct MyApp: App {
    init() {
        EncatchFormHost.install()

        Task {
            try await Encatch.shared.initialize(apiKey: "your-api-key")
        }
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

The API is async/await — wrap calls in a Task { } when calling from synchronous contexts.

import UIKit
import Encatch

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        EncatchFormHost.install()

        Task {
            try await Encatch.shared.initialize(apiKey: "your-api-key")
        }
        return true
    }
}

Pass an optional EncatchConfig to customize SDK behavior:

try await Encatch.shared.initialize(
    apiKey: "your-api-key",
    config: EncatchConfig(
        theme: .system,
        isFullScreen: false,
        debugMode: true,
        appVersion: "1.2.3",
        onBeforeShowForm: { payload in
            // Return false to prevent the form from showing
            return true
        }
    )
)

Prop

Type

2. Identify users

Identify the current user. The userName is required (can be a username, email, or unique identifier). Traits and options are optional.

Username format

userName must be an ASCII identifier: 1–50 characters, using only letters A–Z / a–z, digits 0–9, and ., _, @, -. Spaces and non-English characters (Unicode, accented letters, emoji, etc.) are not supported. Use an email address, internal user ID, or ASCII username — for example user@example.com or user_123. To store a display name in another language, pass it as a trait instead (e.g. set: ["display_name": .string("…")]).

try await Encatch.shared.identifyUser(userName: "user@example.com")
try await Encatch.shared.identifyUser(
    userName: "user@example.com",
    traits: UserTraits(
        set: ["name": .string("Alice"), "plan": .string("team")]
    )
)
try await Encatch.shared.identifyUser(
    userName: "user@example.com",
    traits: UserTraits(
        set: ["name": .string("Alice"), "plan": .string("team")],
        setOnce: ["firstSeen": .string(ISO8601DateFormatter().string(from: Date()))],
        increment: ["loginCount": 1],
        decrement: ["credits": 5],
        unset: ["trialEndDate"]
    )
)

Prop

Type

User traits support the following operations:

OperationTypeDescription
set[String: JSONValue]?Set user attributes (overwrites existing values)
setOnce[String: JSONValue]?Set user attributes only if they don't already exist
increment[String: Double]?Increment numeric user attributes
decrement[String: Double]?Decrement numeric user attributes
unset[String]?Remove user attributes

Recommended

Using the secure option with a server-generated signature is recommended to verify that identification requests come from your backend. Keep your secret key on the server only — never expose it in client-side code.

Pass a server-generated HMAC signature so Encatch can validate the request. generatedDateTimeInUtc must be milliseconds since the Unix epoch (the string form of the epoch-milliseconds timestamp from your server). When your publishable key has a session timeout, use the same value in HMAC-SHA256(userName + epochMs, secretKey). It is sent as the X-User-Signature-Time header and limits the signature's lifespan.

try await Encatch.shared.identifyUser(
    userName: "user@example.com",
    options: IdentifyOptions(
        secure: SecureOptions(
            signature: "your-hmac-signature",
            generatedDateTimeInUtc: "1741867200000" // ms since epoch (2025-03-13T12:00:00Z)
        )
    )
)

3. Show a form manually

Show a specific form by slug or ID.

try await Encatch.shared.showForm("feedback-form")
try await Encatch.shared.showForm("feedback-form", options: ShowFormOptions(
    reset: .always
))

Prop

Type

Prop

Type

ResetModeBehavior
.alwaysReset pre-fill and response data on every form display
.onCompleteReset only after the form is completed
.neverNever reset response data

Pass caller context when showing a form. ContextValue supports .string, .number, .boolean, and .date(epochMillis:):

try await Encatch.shared.showForm("feedback-form", options: ShowFormOptions(
    reset: .always,
    context: [
        "plan": .string("team"),
        "feature": .string("checkout"),
        "seats": .number(12),
        "trial": .boolean(false),
    ]
))

Other actions

Set the user's preferred language.

Encatch.shared.setLocale("fr")

Set the user's country.

Encatch.shared.setCountry("FR") // ISO 3166 country code

Set the theme for forms and surveys.

Encatch.shared.setTheme(.dark)
Encatch.shared.setTheme(.light)
Encatch.shared.setTheme(.system) // Follows system preference

try await Encatch.shared.trackEvent("button_clicked")

try await Encatch.shared.trackScreen("HomeScreen")

A common pattern in SwiftUI is tracking from onAppear:

struct HomeView: View {
    var body: some View {
        content
            .onAppear {
                Task { try? await Encatch.shared.trackScreen("HomeScreen") }
            }
    }
}

In UIKit, call it from viewDidAppear(_:).

Subscribe to form lifecycle events. Returns an unsubscribe closure.

let unsubscribe = Encatch.shared.on { eventType, payload in
    print("Event: \(eventType), formId: \(payload.formId ?? "-"), data: \(payload.data ?? [:])")
}

// Later, to unsubscribe:
unsubscribe()
EventDescription
.formShowFired when a form is displayed
.formStartedFired when a user starts interacting
.formSubmitFired when a form is submitted
.formCompleteFired when a form is fully completed
.formCloseFired when a form is closed
.formDismissedFired when a form is dismissed without completion
.formErrorFired when an error occurs
.formSectionChangeFired when the visible section changes
.formAnsweredFired when a question is answered
.formRemindMeLaterFired when the user taps "Remind me later"
.formCtaTriggeredFired when a completion CTA is triggered on thank-you or exit screens

Handle completion CTAs (in-app navigation, internal redirect, or external browser) via .formCtaTriggered. Configure actions in the form builder — see Call to action. The SDK closes the form overlay after emitting the event — your app handles in-app navigation:

Encatch.shared.on { eventType, payload in
    guard eventType == .formCtaTriggered else { return }

    guard case .string(let action)? = payload.data?["action"],
          action == "app_navigate" else { return }

    guard case .string(let route)? = payload.data?["route"] else { return }

    // Map route strings to your app's navigation paths
    if route == "billing" || route == "billing/upgrade" {
        DispatchQueue.main.async {
            // e.g. push your billing screen via your router / navigation controller
        }
    }
}

For in-app navigation, the SDK closes the form overlay after emitting the event and expects your app to perform navigation. For URL redirect actions, the SDK opens the URL (via SFSafariViewController or the system browser) and closes the form automatically.

Pre-fill a form response before showing a form. questionId may be a question UUID or a question slug.

Encatch.shared.addToResponse(questionId: "question_id", value: "pre-filled value")
Encatch.shared.addToResponse(questionId: "choice_question_id", value: ["option-a", "option-b"])

try await Encatch.shared.showForm("your-form-slug")

Dismiss the currently displayed form.

try await Encatch.shared.dismissForm()
// Or dismiss a specific form configuration:
try await Encatch.shared.dismissForm("config-id")

Use onBeforeShowForm in EncatchConfig to conditionally block forms from showing.

try await Encatch.shared.initialize(
    apiKey: "your-api-key",
    config: EncatchConfig(
        onBeforeShowForm: { payload in
            // Inspect payload.formId, payload.formConfig, payload.triggerType, etc.
            if payload.triggerType == .automatic && someCondition {
                return false // Block this form
            }
            return true // Allow
        }
    )
)

Returning false also clears any pending pre-filled responses. This is the entry point for rendering your own native form UI — see Build Your Own Form UX & UI.

identifyUser starts a session automatically. You can also control session lifecycle manually:

try await Encatch.shared.startSession()

// Skip the immediate ping or screen re-track on start:
try await Encatch.shared.startSession(StartSessionOptions(
    skipImmediatePing: true,
    skipImmediateTrackScreen: true
))
// Temporarily stop the 30-second background ping (not persisted)
Encatch.shared.pauseSession()

// Resume the ping interval after pauseSession()
Encatch.shared.resumeSession()
// Fully suspend SDK activity — stops ping and dismisses open forms.
// Persists across app restarts. Re-enable with startSession().
try await Encatch.shared.stopSession()

Reset the current user identity and clear persisted identity data. Reverts the SDK to anonymous mode. User identity is preserved across stopSession() — use resetUser() after logout.

try await Encatch.shared.resetUser()

Wipes all persisted SDK data and resets in-memory state. Stronger than resetUser() — also clears session-stopped state and device preferences. Call initialize and identifyUser again afterward.

try await Encatch.shared.clearAll()

The SDK sends a background ping every 30 seconds (configurable via server response) to maintain engagement sessions and check for triggered forms. Ping is suppressed while a form is visible.


Inline Forms

Inline forms are a way to show Encatch in-app feedback without a modal — the survey renders directly in your layout instead of as a full-screen overlay.

EncatchInlineFormView is a UIView that claims a form id, so showForm for that id renders inline in your layout instead of as a modal. Leave formId as nil to make it a wildcard slot that catches any form id not claimed elsewhere.

The package does not ship a SwiftUI wrapper, so SwiftUI hosts wrap EncatchInlineFormView in a UIViewRepresentable and drive the frame from onHeightChange:

import SwiftUI
import Encatch

struct InlineFormRepresentable: UIViewRepresentable {
    let formId: String?
    @Binding var height: CGFloat

    func makeUIView(context: Context) -> EncatchInlineFormView {
        let view = EncatchInlineFormView()
        view.formId = formId
        view.onHeightChange = { [binding = $height] newHeight in
            DispatchQueue.main.async { binding.wrappedValue = newHeight }
        }
        return view
    }

    func updateUIView(_ uiView: EncatchInlineFormView, context: Context) {}
}

struct InlineFormSlot: View {
    let formId: String?
    @State private var height: CGFloat = 0

    var body: some View {
        InlineFormRepresentable(formId: formId, height: $height)
            .frame(height: max(height, 64))
            .animation(.easeOut(duration: 0.2), value: height)
    }
}

Place the slot in a ScrollView, then trigger the form from anywhere:

ScrollView {
    VStack(spacing: 20) {
        // ... content above ...
        InlineFormSlot(formId: "your-form-slug") // exact match; pass nil for wildcard
        // ... content below ...
    }
}
try await Encatch.shared.showForm("your-form-slug")

UIKit hosts add EncatchInlineFormView directly. With Auto Layout, the view sizes itself via its own height constraint — no onHeightChange needed:

import UIKit
import Encatch

final class FeedbackViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let inlineForm = EncatchInlineFormView()
        inlineForm.formId = "your-form-slug" // or nil for a wildcard slot
        inlineForm.minHeight = 64
        inlineForm.translatesAutoresizingMaskIntoConstraints = false

        contentStackView.addArrangedSubview(inlineForm)
    }
}

For manual layout, use onHeightChange to drive your own frames:

inlineForm.onHeightChange = { newHeight in
    // update your layout with newHeight
}

Then trigger the form from anywhere:

try await Encatch.shared.showForm("your-form-slug")

When showForm is called, the SDK resolves the presenter in this order:

  1. Exact match — first registered EncatchInlineFormView whose formId matches the payload wins.
  2. Wildcard — first registered EncatchInlineFormView with formId == nil catches anything not exact-matched.
  3. Modal fallback — EncatchFormHost shows the form as the default overlay when no inline slot is registered or none match.

Slot registration is tied to the view's window attach/detach lifecycle — a view removed from the window (e.g. its screen popped off the navigation stack) unregisters automatically, and showForm falls through to the modal or another active slot.

The WebView's internal scroll is disabled. The host scroll view (ScrollView in SwiftUI, UIScrollView/stack in UIKit) provides scrolling. The view's height grows automatically via form:resize messages from the web form:

  • Auto Layout hosts need nothing — the view maintains its own height constraint.
  • SwiftUI / manual-layout hosts bind onHeightChange to their own frame instead of hardcoding a height.
  • minHeight sets a floor (in points) applied after resize messages.

The host app controls keyboard avoidance — use standard keyboardLayoutGuide (UIKit) or SwiftUI's automatic keyboard avoidance to slide content above the keyboard.

PropertyTypeDefaultDescription
formIdString?nilExact form slug/id to match. nil = wildcard.
minHeightCGFloat0Minimum height floor applied after form:resize.
onHeightChange((CGFloat) -> Void)?nilCalled whenever the view's self-sized height changes.
onOverlayOpenChange((Bool) -> Void)?nilCalled when a QnA/Scheduler overlay opens or closes.

Build Your Own Form UX & UI

If your feedback flow uses a fixed, predictable question set — the same fields and workflow every time — you can build the form with your own native views and submit responses through the SDK. That keeps typography, spacing, colors, and interaction patterns aligned with the rest of your app, so the survey feels like a native screen rather than an embedded web page.

The flow has two parts:

  1. Intercept the form with onBeforeShowForm and return false — the SDK hands you the full form configuration (payload.formConfig) and skips its own UI.
  2. Submit responses with buildSubmitRequest + submitForm once the user completes your native form.
try await Encatch.shared.initialize(
    apiKey: "your-api-key",
    config: EncatchConfig(
        onBeforeShowForm: { payload in
            guard payload.formId == "my-native-form" else { return true }
            // Present your own UI using payload.formConfig
            // (questionnaireFields, appearanceProperties, etc.)
            await MyNativeSurveyPresenter.shared.present(config: payload.formConfig)
            return false // SDK will not render its own form
        }
    )
)

When the user finishes, map each answer to a NativeFormResponse and build the submit request. buildSubmitRequest covers all 33 question types — numeric scales take numbers, choice types take String or [String], boolean types take Bool, matrix types take dictionaries:

let responses = [
    NativeFormResponse(questionId: "q1", type: "rating", value: 5),
    NativeFormResponse(questionId: "q2", type: "short_answer", value: "Great product!"),
    NativeFormResponse(questionId: "q3", type: "multiple_choice_multiple", value: ["speed", "design"]),
    NativeFormResponse(questionId: "q4", type: "yes_no", value: true),
]

let request = buildSubmitRequest(
    BuildSubmitRequestOptions(
        formConfigurationId: formConfig.feedbackConfigurationId,
        completionTimeInSeconds: 42
    ),
    responses: responses
)

try await Encatch.shared.submitForm(request)

Prop

Type


All methods are called on the Encatch.shared singleton.

MethodDescription
initialize(apiKey:config:)Initialize the SDK
identifyUser(userName:traits:options:)Identify a user
setLocale(_:)Set locale
setCountry(_:)Set country (ISO 3166)
setTheme(_:)Set form theme
trackEvent(_:)Track a custom event
trackScreen(_:)Track screen navigation
showForm(_:options:)Show a form (inline or modal)
dismissForm(_:)Dismiss the current form
addToResponse(questionId:value:)Pre-fill a question answer
startSession(_:)Start a new session
pauseSession()Pause background ping
resumeSession()Resume background ping
stopSession()Suspend SDK activity
resetUser()Reset user identity
clearAll()Wipe all persisted SDK data
on(_:)Subscribe to lifecycle events (returns unsubscribe closure)
emitEvent(_:_:)Emit a lifecycle event manually
submitForm(_:)Submit a custom native form
flushRetryQueue()Flush the offline retry queue (e.g. on foreground)
stop()Teardown — stops the ping loop
isInitializedWhether initialize has completed

EncatchFormHost.install() (call once at launch) mounts the modal form presenter; EncatchInlineFormView renders forms inline.

Add usage descriptions to your app's Info.plist when forms include video/audio capture questions (video_audio):

<key>NSCameraUsageDescription</key>
<string>Encatch forms use the camera to record video responses.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Encatch forms use the microphone to record audio and video responses.</string>

No other setup is required. The SDK uses WKWebView, which surfaces the system media permission prompts once these keys are present. Network access needs no entitlement in a standard iOS app; Mac Catalyst targets need the Outgoing Connections (Client) App Sandbox capability.

Support

Was this page helpful?