iOS

Add Kustomer Chat to an iOS app with the KustomerChat Swift SDK. Swift Package Manager and CocoaPods setup, Kustomer.setup, presenting the chat UI, the headless ChatProvider and KUSChatListener, identity, theming, localization, and the privacy manifest.

The iOS SDK is a single Swift module, KustomerChat. It contains both the prebuilt chat and knowledge base UI and the headless API underneath it, so every integration starts with import KustomerChat.

SDK8.3.0
iOS15.0+
SwiftSwift 6 toolchain
DistributionSwift Package Manager, CocoaPods, or the XCFramework

Sample apps for SPM and CocoaPods: kustomer/ios-chat-sdk-samples.

Install

In Xcode, choose File › Add Package Dependencies… and enter:

Text
https://github.com/kustomer/kustomer-ios-spm

Use Up to Next Major Version from 8.3.0 and add the kustomer-ios product to your app target. Or in Package.swift:

Package.swift
dependencies: [
  .package(url: "https://github.com/kustomer/kustomer-ios-spm", from: "8.3.0"),
],
targets: [
  .target(name: "MyApp", dependencies: [
    .product(name: "kustomer-ios", package: "kustomer-ios-spm"),
  ]),
]

The package is a prebuilt binary with no package dependencies to resolve.

Info.plist

Add usage descriptions so customers can send photos and videos:

KeyWhy
NSCameraUsageDescriptionTaking photos or videos to attach
NSMicrophoneUsageDescriptionRecording video with sound
NSPhotoLibraryUsageDescriptionRecommended for attaching from the library

Files are picked with the system document picker, which needs no permission.

Set up

  1. Get a chat key

    Create an API key in Settings › Security › API Keys with only the org.tracking role and no expiration. Setup fails with any other roles.

  2. Call setup at launch

    AppDelegate.swift
    import UIKit
    import KustomerChat
    
    @main
    class AppDelegate: UIResponder, UIApplicationDelegate {
      func application(_ application: UIApplication,
                       didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        let options = KustomerOptions()
        options.brandId = "YOUR_BRAND_ID"
        options.logLevels = [.error, .warning]
    
        _ = Kustomer.setup(apiKey: kustomerChatKey, options: options, launchOptions: launchOptions) { result in
          if case .failure(let error) = result {
            print("Kustomer setup failed: \(error)")
          }
        }
        return true
      }
    }
    • Call it from application(_:didFinishLaunchingWithOptions:). In a SwiftUI app, use @UIApplicationDelegateAdaptor.
    • setup returns the client, and the result isn't discardable, so assign it to _.
    • Call it once. A second call fails with alreadyConfigured, and options can't be replaced after setup.
  3. Open chat

    Swift
    Kustomer.show()

Kustomer is a global, so every call is Kustomer.method(…). Kustomer.configure(…) still works but is deprecated: setup always calls its completion. Check progress with Kustomer.status, which goes from uninitialized to configured, started and connected.

Options

KustomerOptions is a class; set properties before passing it to setup.

PropertyDefaultDescription
brandIdOrg defaultBrand whose chat settings and knowledge base to use
businessScheduleIdOrg defaultBusiness schedule used for availability
languageDevice languageForce a KustomerLanguage, such as .fr or .zh_CN
activeAssistant.orgDefault.none, .orgDefault or .withId("…"). UI only.
enableUItruefalse for a headless integration. Also turns off in-app notifications.
showInAppNotificationstrueBanners for new messages while your app is in the foreground
hideNewConversationButtonfalseNever show New conversation
hideNewConversationButtonInClosedChatfalseHide it only in ended conversations
hideHistoryNavigationfalseHide the back button to chat history
hideKbArticleSharefalseHide sharing on knowledge base articles
showAllBrandsConversationHistoryfalseShow every brand's conversations in history
enableHistoryStatusFilterfalseStatus filter menu on history: All, Open, Closed, Unread
historyStatusFilter.noneInitial filter: .none, .open, .closed or .requiresAttention
enableUrlUnfurlingtrueLink previews in messages
showAnimationFor5StarCSATRatingsfalseCelebrate 5-star satisfaction ratings
maxChatAttachmentTotalMB50Total attachment size per message, 0–50
messageAttachmentLocalCacheMaximumSizeInBytes25 MBAttachment cache size. Minimum 10 MB.
disablePushfalseDon't register for push. A device that's already registered keeps receiving pushes.
pushEnvironment.production.development for debug builds
pushBundleIdYour bundle IDBundle ID to register push under
logLevels[] (off)Any of .debug, .info, .warning, .error; KustomerLogTypeAll for everything
onShow, onDismissnilClosures called when the chat UI appears and goes away

Change some settings at runtime:

Swift
Kustomer.overrideBrandId("OTHER_BRAND_ID") { error in }
Kustomer.changeBusinessSchedule(scheduleId: "SCHEDULE_ID") { result in }
Kustomer.changeActiveAssistant(.withId("ASSISTANT_ID"))
Kustomer.setHistoryStatusFilter(.open)

Present the UI

UI methods are safe to call from any thread; the SDK moves to the main thread itself.

Swift
Kustomer.show()                                              // follows the brand's widget setting
Kustomer.show(preferredView: .onlyChat)
Kustomer.show(preferredView: .knowledgeBase, presentingVC: self)

Kustomer.startNewConversation(
  initialMessage: KUSInitialMessage(body: "Where is my order?", direction: .user),
  customDescribe: ["orderNumberStr": "4417"],
  afterCreateConversationOrError: { result in
    if case .success(let conversation) = result { print(conversation.id) }
  }
)

Kustomer.openConversation(id: "6716a3a9c4e5f60012ab34c1") { result in }
Kustomer.openChatAssistant(id: "ASSISTANT_ID", startDialog: nil, completion: { result in })
Kustomer.showKbArticle(id: "ARTICLE_ID")
Kustomer.showKbCategory(id: "CATEGORY_ID")
Kustomer.close(completion: nil)
Kustomer.isVisible
KustomerDisplayModeShows
.defaultThe experience chosen in the brand's chat widget settings
.chatHistoryChat history
.newChatA new conversation
.activeChatThe most recent active conversation
.knowledgeBase, .knowledgeBaseHomeThe knowledge base
.onlyChatChat only, without the knowledge base tab
.onlyKnowledgeBaseKnowledge base only, without the chat tab
  • A .user initial message is sent and creates the conversation. A .agent one is shown as a greeting until the customer replies.
  • customDescribe sets conversation custom attributes, using attribute names with type suffixes.
  • In apps that use UIScene, pass presentingVC: so the SDK presents from the right window.
  • openChatAssistant and close have parameters without defaults, so startDialog: and completion: are required, as above.

Unread counts and availability

Swift
Kustomer.getUnreadCount { result in
  if case .success(let count) = result { badge.text = count > 0 ? "\(count)" : nil }
}

if Kustomer.isChatAvailable() == false {
  // chat is off, or the business schedule says it's closed
}

isChatAvailable() returns nil before chat settings have loaded, false when chat is off or outside business hours, and true otherwise. getOpenConversationCount counts conversations that aren't ended and had a message in the last 30 days.

Headless: ChatProvider

For your own UI, set options.enableUI = false and use Kustomer.chatProvider:

Swift
let chat = Kustomer.chatProvider

chat.createConversation(firstCustomerMessage: "Where is my order?", title: "Order 4417") { result in
  switch result {
  case .success(let created):
    chat.sendChatMessage(text: "It was due Monday.", conversationId: created.conversation.id) { _ in }
  case .failure(let error):
    print(error)
  }
}
MethodPurpose
getConversationPage(page:pageSize:completion:)The customer's conversations, paged. Prefer it over getConversations, which loads everything.
getConversation(conversationId:completion:)One conversation
getActiveConversations(completion:)Conversations that haven't ended
createConversation(firstCustomerMessage:title:customDescribe:…:completion:)Create a conversation. Returns the conversation, its messages and the customer.
endConversation(conversationId:completion:)End it
getChatMessages(conversationId:completion:)Messages, oldest first
getChatMessagesBefore(timestamp:conversationId:count:completion:)Older messages, newest first
sendChatMessage(text:conversationId:completion:)Send text. Overloads send an image (image:), a file (fileUrl:) or a button action (action:).
markSessionAsRead(sessionId:completion:)Mark a conversation read
listenForTyping(conversationId:) / stopListeningForTyping(conversationId:)Agent typing indicators
sendTypingStatus(for:isTyping:)The customer's typing indicator
sendPresenceActivity(_:).online or .offline
describeConversation(conversationId:attributes:_:)Conversation custom attributes
reloadChatSettings(_:)Refresh chat settings. Don't call it on every launch.

With enableUI = false, start and stop the connection yourself with Kustomer.start() and Kustomer.stop(). Never call stop() when you use the SDK's UI.

The knowledge base is on Kustomer.kbProvider: getRootCategory(lang:knowledgeBaseID:completion:), getCategory(by:childrenLimit:lang:knowledgeBaseID:completion:), searchArticles(term:page:pageSize:lang:completion:) and getArticle(by:lang:knowledgeBaseID:completion:). Satisfaction surveys are handled by the UI only.

Listen for events

Conform to KUSChatListener. Every method has a default implementation, so implement only the ones you need, and keep the returned handle to remove the listener later.

Swift
final class ChatEvents: KUSChatListener {
  func onChatMessageReceived(conversationId: String, chatMessage: KUSChatMessage) { }
  func onUnreadCountChange(count: Int) { }
  func onUserIsTyping(conversationId: String, user: KUSUser, timetoken: KUSPNTtoken?, ai: Bool) { }
  func onUserStopsTyping(conversationId: String, user: KUSUser, timetoken: KUSPNTtoken?) { }
  func onConversationEnded(conversationId: String, conversation: KUSConversation) { }
}

let events = ChatEvents()
let handle = Kustomer.chatProvider.addChatListener(events)
// later
Kustomer.chatProvider.removeChatListener(handle)
CallbackFires when
onChatMessageReceived(conversationId:chatMessage:)A message arrives. Order isn't guaranteed; sort by createdAt.
onUnreadCountChange(count:), onUnreadCountChange(conversationId:count:)Unread counts change. Called on the main thread.
onOpenConversationCountChange(count:)The open conversation count changes
onUserJoined(conversationId:user:)An agent joins
onUserIsTyping(…ai:), onUserStopsTyping(…)Agent or AI typing starts and stops
onPreviewChanged(conversationId:preview:previewDetails:)The conversation preview text changes
onConversationCreated, onConversationEnded, onConversationReOpened, onConversationDeletedConversation lifecycle, each with (conversationId:conversation:)
onConversationMerged(sourceConversation:targetConversation:)Two conversations are merged
onAssistantEnded(conversation:)An assistant finishes
onSatisfactionEventReceived(conversationId:satisfaction:)A satisfaction survey is sent
onCustomerDeleted()The customer was deleted
onSessionOpened, onSessionTitleChanged, onSessionCustomAttributesUpdatedConversation opened, renamed or updated
onUrlPreviewReceived(conversationId:message:urlUnfurled:)A link preview is ready

Connection state is on ConnectionProvider.shared: status is connecting, connected, reconnecting, disconnected or disconnectedUnexpectedly, and a KUSConnectionListener gets onPubNubConnectionStatusChange(status:).

Identity

Swift
if !Kustomer.isLoggedIn(userEmail: nil, userId: user.id) {
  Kustomer.logIn(jwt: tokenFromServer) { result in
    switch result {
    case .success(let identity): print("Chat customer", identity.customerId)
    case .failure(let error): print(error)
    }
  }
}

Kustomer.chatProvider.describeCurrentCustomer(sharedPhones: ["+15555550123"], custom: ["loyaltyTierStr": "gold"])

// on sign-out
Kustomer.logOut { error in }
  • The identity is stored in the keychain, so check isLoggedIn instead of logging in on every launch. It compares userId with the token's externalId (case-insensitive), or userEmail with its email.
  • A successful login clears the local conversation cache and rebuilds the UI. The completion runs on the main thread, and calling Kustomer.show() inside it is safe.
  • logOut clears local state and deregisters push. It reports an error only if push deregistration failed, for example offline; local state is still cleared.
  • logOutThenLogIn(jwt:_:) switches users in one call.
  • In describeCurrentCustomer, use the shared* parameters; email, phone, emails and phones are deprecated.

The JWT comes from your server. See Customer identity.

Push notifications

setup registers for remote notifications. Forward the device token from your AppDelegate:

Swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  Kustomer.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
  Kustomer.didFailToRegisterForRemoteNotifications(error: error)
}

APNs keys, permission prompts and notification delegates are covered in Push notifications.

Theming

Colors are customized by name, in either of two ways:

  • Asset catalog. Add a color set named KustomerColor.<name> to your app's asset catalog, with Any and Dark appearances.
  • Code. Assign KustomerUI.Color.<name> before calling setup.
Swift
KustomerUI.Color.outgoingMessageBubble = UIColor(named: "Brand")!
KustomerUI.Color.chatHeaderBackground = .systemBackground
AreaColor names
MessagesincomingMessageBubble, incomingMessageText, outgoingMessageBubble, outgoingMessageText, messagesBackground, messageUrlLinkOnAgentBubble, messageUrlLinkOnCustomerBubble
Chat screenchatHeaderBackground, chatHeaderTitle, chatHeaderText, chatInputBarBackground, chatTextInputBackground, sendChatMessageButton, chatNewConversationButton, chatNewConversationButtonText
HistoryhistoryBackground, historyHeaderBackground, historyRowBackground, historyRowTitle, historyRowSubtitle, historyBadgeBackground, historyBadgeText, historyNewChatButton
Knowledge basekbHeader, kbHeaderText, kbBrowseBackground, kbBrowseRowTitle, kbBrowseRowSubtitle, kbBrowseIconTint
NavigationnavigationBarButton, tabBarBackground, tabBarItemColor, tabBarUnselectedItemColor
ButtonsquickActionButton, quickActionButtonText, quickActionButtonBorder, kobjectButton, kobjectButtonText
SatisfactioncsatText, csatCommentBoxBackground, csatCommentBoxOutline

The built-in colors support dark mode. Text uses Dynamic Type; there's no font API. On iOS 26 and later the UI uses the system's Liquid Glass styling, unless your app sets UIDesignRequiresCompatibility in Info.plist.

Localization

The SDK follows the device language; set options.language to force one. To change any string, add a localized Kustomer.strings file (for example in en.lproj) with the keys you want to override:

en.lproj/Kustomer.strings
"com.kustomer.week" = "week";

Run Kustomer.printLocalizationKeys() once to print every key. Strings are looked up in your localized Kustomer.strings first, then the SDK's own translation. A Kustomer.strings that isn't localized loses to the SDK's translations.

Privacy

The SDK includes a privacy manifest (PrivacyInfo.xcprivacy). It declares required-reason APIs for disk space, file timestamps, user defaults and system boot time, declares no collected data types, and doesn't track. Xcode merges it into your app's privacy report.

Troubleshooting

SymptomFix
Setup fails: "API key must contain only the org.tracking role"Use a key with only org.tracking.
Result of call to 'setup' is unused warningWrite _ = Kustomer.setup(…).
alreadyConfiguredsetup ran twice. Call it once, at launch.
Typing indicator callback never firesYour method is missing ai: Bool.
Chat opens behind other UI, or not at all, in a scene-based appPass presentingVC:.
String overrides ignoredLocalize Kustomer.strings.
Kustomer.close() doesn't compileUse Kustomer.close(completion: nil).