Feedback SDK / iOS

iOS Feedback SDK Integration Guide

Let users submit ideas, bugs, feedback, and questions; vote on public items; and identify themselves or stay anonymous. Feedback reuses the apiKey and baseURL from GrowthCat.initialize.

Platform

Native iOS apps using the GrowthCat SDK.

Use Case

Collect private feedback and public idea votes with user identity, anonymous identity, and app metadata.

Visibility

Only idea items are public. Bugs, feedback, and questions stay private in the dashboard.

Overview

The Feedback SDK provides a native board-first feedback experience. Users can browse and vote on public ideas, open the compose sheet from the floating + button, submit private bug reports and questions, and keep using an anonymous identity until your app identifies them.

Note on visibility

The backend only ever makes idea items public. Bug, feedback, and question submissions are always private, never appear on the public board, and never count toward votes from other users. Plan your UI copy accordingly: a submitted bug report will not show up for others to upvote.

Configuring The Feedback SDK

Import GrowthCat and call GrowthCat.initialize once before using feedback. GrowthCatFeedback.configure is only for feedback-specific options like user identity, theme, strings, and metadata filtering.

swift
import GrowthCat

GrowthCat.initialize(
    apiKey: "gc_live_your_key_here",
    logsEnabled: growthCatOptions.logsEnabled,
    baseURL: baseURL
)

GrowthCatFeedback.configure(
    user: FeedbackUser(
        id: "customer_123",
        email: "jane@example.com",
        name: "Jane"
    )
)

Omit user to start anonymous. The SDK creates and persists an anonymous ID for you.

Identifying Feedback Users

Call identify once the app knows who the visitor is. Future submissions and votes use userId instead of the anonymous ID.

swift
GrowthCatFeedback.identify(
    FeedbackUser(id: "customer_123", email: "jane@example.com", name: "Jane")
)

Call clearUser on sign-out so the next signed-in user is not linked to the previous identity. Future submissions and votes fall back to the already-persisted anonymous ID.

swift
GrowthCatFeedback.clearUser()

Anonymous Feedback Users

If no user is configured, the SDK generates an anonymous ID, such as anon_6a4d72, and persists it in the Keychain so it survives app relaunches. You never need to manage it manually.

swift
let anonymousId = GrowthCatFeedback.shared.anonymousId

Submitting Feedback Programmatically

swift
let result = try await GrowthCatFeedback.submit(
    FeedbackSubmission(
        type: .bug,
        title: "Export button crashes",
        body: "When I tap Export from the reports screen the app freezes.", // optional
        metadata: ["screen": "ReportsView"]
    )
)

print(result.itemId, result.status, result.voteCount)

FeedbackType is one of .idea, .bug, .feedback, or .question. FeedbackSubmission.body is optional and omitted from the request when empty. FeedbackSubmitResult.status is one of .new, .triage, .todo, .inProgress, .done, or .closed.

Presenting The Native Feedback Sheet

presentFeedbackSheet opens the board as the first screen, with the compose sheet reachable from its floating + button.

swift
GrowthCatFeedback.shared.presentFeedbackSheet(
    from: viewController,
    preselectedType: .bug,       // optional — pre-applies a type filter; nil shows everything
    metadata: ["screen": "ReportsView"]
)

Board first screen

  • A horizontally scrolling filter row with All plus one chip per FeedbackType filters the list client-side.
  • Each row shows an upvote control, vote count, type tag, title, and optional body preview.
  • Tapping the vote control calls vote or unvote optimistically, then reconciles with the server voteCount and hasVoted values.
  • Pull-to-refresh, an empty state, and a load-error state with retry are built in.
  • The floating + button opens compose, pre-filled with the active filter or .feedback when All is selected.

Compose sheet

  • Includes type selector, title field, body field, optional email field for anonymous users, submit button, and success and error states.
  • Returning from compose automatically refreshes the board.
  • Both screens support Dark Mode through light and dark color pairs resolved against the system appearance.

Customizing Feedback Appearance And Text

Configure colors and strings at setup time so the UI matches your app and can be localized. Each color is a light/dark pair so it adapts automatically.

swift
GrowthCatFeedback.configure(
    theme: GrowthCatFeedbackTheme(
        accent: FeedbackHexPair(light: "#0A84FF", dark: "#409CFF"),
        background: FeedbackHexPair(light: "#F2F2F7", dark: "#000000"),
        card: FeedbackHexPair(light: "#FFFFFF", dark: "#1C1C1E"),
        primaryText: FeedbackHexPair(light: "#111111", dark: "#F5F5F5"),
        secondaryText: FeedbackHexPair(light: "#6B6B70", dark: "#9B9BA1"),
        border: FeedbackHexPair(light: "#DDDDDD", dark: "#38383A")
    ),
    strings: GrowthCatFeedbackStrings(
        title: "Send Feedback",
        cancel: "Close",
        submit: "Submit request",
        submitting: "Submitting",
        successTitle: "Request sent",
        typeLabel: "Category",
        titleLabel: "Summary",
        titlePlaceholder: "What should we add?",
        detailsLabel: "Details",
        detailsPlaceholder: "Tell us what you would like to see",
        emailLabel: "Email (optional)",
        emailPlaceholder: "you@example.com",
        errorFallback: "Something went wrong. Please try again.",
        typeLabels: [
            .idea: "Request",
            .bug: "Bug",
            .feedback: "Feedback",
            .question: "Question"
        ],
        boardTitle: "Feedback",
        allFilterLabel: "All",
        emptyBoardTitle: "No feedback yet",
        emptyBoardMessage: "Be the first to share an idea, report a bug, or ask a question.",
        loadErrorFallback: "Couldn't load feedback. Please try again.",
        retry: "Retry"
    )
)

Use #RRGGBB hex strings for both light and dark. FeedbackHexPair(_:) accepts a single hex if you do not want to vary by appearance, and the single-hex convenience initializer remains available for existing call sites. Any omitted theme or string value falls back to the SDK default.

Feedback Voting

Voting is for public items only. One vote per user is enforced server-side; treat duplicate-vote responses as normal and refresh from the returned voteCount. The native board does this automatically.

swift
let voteResult = try await GrowthCatFeedback.vote(itemId: "fit_123")
let unvoteResult = try await GrowthCatFeedback.unvote(itemId: "fit_123")

To fetch the board yourself, optionally filtered by type:

swift
let allItems = try await GrowthCatFeedback.fetchBoard()
let bugsOnly = try await GrowthCatFeedback.fetchBoard(type: .bug)

Feedback Automatic Metadata

Every submission and vote automatically includes app and device context.

json
{
  "platform": "ios",
  "app_version": "1.2.0",
  "build_number": "42",
  "os_version": "26.0",
  "device_model": "iPhone17,2",
  "locale": "en-US"
}
  • app_version comes from CFBundleShortVersionString, and build_number comes from CFBundleVersion.
  • No raw screenshots, logs, or IP addresses are ever collected by the SDK.
  • You can disable individual automatic fields with disabledAutomaticMetadataKeys.
  • You can add or override metadata globally with setMetadata, or per submission with FeedbackSubmission(metadata:).
swift
GrowthCatFeedback.configure(
    disabledAutomaticMetadataKeys: ["device_model"]
)
swift
GrowthCatFeedback.setMetadata(["plan": "pro"])

Feedback Error Handling

swift
public enum GrowthCatFeedbackError: Error {
    case notConfigured
    case invalidPayload(message: String?)
    case networkError(message: String?)
    case boardNotFound
    case feedbackDisabled
    case planLimitReached
    case rateLimited(retryAfter: TimeInterval?)
    case serverError(statusCode: Int, message: String?)
}
swift
do {
    _ = try await GrowthCatFeedback.submit(
        FeedbackSubmission(type: .bug, title: "Export button crashes", body: "It freezes.")
    )
} catch GrowthCatFeedbackError.planLimitReached {
    print("Feedback is temporarily unavailable.")
} catch {
    print(error.localizedDescription)
}

Feedback API Reference

swift
public enum GrowthCatFeedback {
    public static var isConfigured: Bool
    public static var shared: GrowthCatFeedbackClient

    public static func configure(
        user: FeedbackUser? = nil,
        theme: GrowthCatFeedbackTheme = GrowthCatFeedbackTheme(),
        strings: GrowthCatFeedbackStrings = GrowthCatFeedbackStrings(),
        disabledAutomaticMetadataKeys: Set<String> = []
    )

    public static func identify(_ user: FeedbackUser)
    public static func clearUser()
    public static func setMetadata(_ metadata: [String: String])
    public static func submit(_ submission: FeedbackSubmission) async throws -> FeedbackSubmitResult
    public static func vote(itemId: String) async throws -> FeedbackVoteResult
    public static func unvote(itemId: String) async throws -> FeedbackVoteResult
    public static func fetchBoard(type: FeedbackType? = nil) async throws -> [FeedbackBoardItem]
}

public struct GrowthCatFeedbackClient {
    public var isConfigured: Bool
    public var anonymousId: String?
    public var currentUser: FeedbackUser?
    public var theme: GrowthCatFeedbackTheme
    public var strings: GrowthCatFeedbackStrings

    public func identify(_ user: FeedbackUser)
    public func clearUser()  // Call on sign-out — reverts to the anonymous ID.
    public func setMetadata(_ metadata: [String: String])
    public func submit(_ submission: FeedbackSubmission) async throws -> FeedbackSubmitResult
    public func vote(itemId: String) async throws -> FeedbackVoteResult
    public func unvote(itemId: String) async throws -> FeedbackVoteResult
    public func fetchBoard(type: FeedbackType? = nil) async throws -> [FeedbackBoardItem]

    // iOS 16+ — presents the board first, with compose reachable via its "+" button.
    @MainActor
    public func presentFeedbackSheet(
        from viewController: UIViewController,
        preselectedType: FeedbackType? = nil,
        metadata: [String: String]? = nil
    )
}

public enum FeedbackType: String { case idea, bug, feedback, question }

public struct FeedbackHexPair {
    public let light: String
    public let dark: String
}

public struct GrowthCatFeedbackTheme {
    public let accent: FeedbackHexPair
    public let background: FeedbackHexPair
    public let card: FeedbackHexPair
    public let primaryText: FeedbackHexPair
    public let secondaryText: FeedbackHexPair
    public let border: FeedbackHexPair
}

public struct GrowthCatFeedbackStrings {
    public let title: String
    public let cancel: String
    public let submit: String
    public let submitting: String
    public let successTitle: String
    public let typeLabel: String
    public let titleLabel: String
    public let titlePlaceholder: String
    public let detailsLabel: String
    public let detailsPlaceholder: String
    public let emailLabel: String
    public let emailPlaceholder: String
    public let errorFallback: String
    public let typeLabels: [FeedbackType: String]
    public let boardTitle: String
    public let allFilterLabel: String
    public let emptyBoardTitle: String
    public let emptyBoardMessage: String
    public let loadErrorFallback: String
    public let retry: String
}

public struct FeedbackUser {
    public let id: String
    public let email: String?
    public let name: String?
}

public struct FeedbackSubmission {
    public let type: FeedbackType
    public let title: String
    public let body: String  // Optional at init; defaults to "" and is omitted when empty.
    public let metadata: [String: String]?
}

// The public-facing item shape — same fields whether it comes from submit,
// board listing, or vote/unvote. There is no visibility field: the backend
// only ever exposes idea items publicly, so anything you can see here is
// implicitly public.
public struct FeedbackSubmitResult {
    public let itemId: String
    public let type: FeedbackType
    public let title: String
    public let body: String?
    public let status: FeedbackItemStatus  // .new, .triage, .todo, .inProgress, .done, .closed
    public let voteCount: Int
    public let commentCount: Int
    public let isPinned: Bool
    public let hasVoted: Bool
    public let createdAt: Date
    public let updatedAt: Date
}

public struct FeedbackVoteResult {
    public let itemId: String
    public let hasVoted: Bool
    public let voteCount: Int
}

public struct FeedbackBoardItem: Identifiable {
    public let itemId: String
    public let type: FeedbackType
    public let title: String
    public let body: String?
    public let status: FeedbackItemStatus
    public let voteCount: Int
    public let commentCount: Int
    public let isPinned: Bool
    public let hasVoted: Bool
    public let createdAt: Date
    public let updatedAt: Date
    public var id: String { itemId }
}