iOS
A Swift package with no dependencies: SwiftUI views and a UIKit view controller over the same conversation, talking to the same public API the website widget does.
1. Install
iOS 15 and later. Add the package in Xcode under File → Add Package Dependencies…, or in Package.swift:
// Package.swift — or File → Add Package Dependencies… in Xcode
dependencies: [
.package(url: "https://github.com/helpwing/helpwing-ios-sdk.git", from: "0.1.0"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Helpwing", package: "helpwing-ios-sdk")]),
]2. Configure the project
Both values are public — the key is the same pk_… the website snippet carries, and shipping it inside an app bundle is expected. Copy yours from the Chat widget screen of your workspace. The API URL is whichever host serves /widget.js, because that is the host the chat endpoints are routed on.
// Both values are public, and both end up in the app bundle.
enum HelpwingConfig {
static let apiUrl = "https://api.helpwing.app"
static let projectKey = "pk_your_project_key"
}Nothing needs adding to the allowed origins list: an app sends no origin at all. What makes a conversation trustworthy is identity verification, further down.
3. Set it up once
// MyApp.swift
import Helpwing
import SwiftUI
@main
struct MyApp: App {
@StateObject private var support = HelpwingSupport(
apiUrl: HelpwingConfig.apiUrl,
projectKey: HelpwingConfig.projectKey
)
var body: some Scene {
WindowGroup {
ContentView()
// The floating button, and the sheet it opens. Both optional.
.supportLauncher()
.supportSheet()
.helpwing(support)
}
}
}One HelpwingSupport, owned by the app rather than a screen, holds the conversation, the polling and the stored token for as long as the app runs. The conversation is kept in UserDefaults; pass storage: to keep it in the Keychain or an App Group instead.
A UIKit app presents or pushes the same chat as a view controller:
let support = HelpwingSupport(apiUrl: HelpwingConfig.apiUrl, projectKey: HelpwingConfig.projectKey)
// Present it modally from any view controller…
support.present(from: self)
// …or push it under a navigation bar that already has a title and a back button.
navigationController?.pushViewController(
SupportViewController(support: support, showsHeader: false), animated: true
)4. Or draw it yourself
The launcher and the sheet are conveniences. An app with a Help row in its own settings wants neither — put SupportChat on a screen and open it however you like.
// HelpRow.swift — your own entry point, with the unread badge
import Helpwing
import SwiftUI
struct HelpRow: View {
@EnvironmentObject var support: HelpwingSupport
var body: some View {
// Being on screen is what marks the visitor as reading.
NavigationLink(destination: SupportChat().navigationTitle("Support")) {
Label("Support", systemImage: "questionmark.bubble")
.badge(support.unreadCount)
}
}
}Or skip the views entirely and draw against the state HelpwingSupport publishes.
// Everything is on HelpwingSupport, and the transcript is a plain array.
let failed = support.messages.filter { $0.delivery == .failed }
// A message that failed carries the id it was sent with, and retrying is free —
// the server stores one message however many times it arrives.
for message in failed {
if let id = message.clientMessageId { await support.retry(id) }
}5. Say who the visitor is
// Wherever your signed-in user lives.
support.identity = Identity(
id: user.id,
email: user.email,
name: user.name,
metadata: ["plan": .string(user.plan)],
// Computed by YOUR server. See below.
userHash: user.supportHash
)Applied whenever it changes, including to a conversation that started before anyone signed in — the conversation moves onto their customer record when they do.
The identity secret never goes in the app
userHash is an HMAC-SHA256 of the user id, keyed with your project's identity secret, and your server computes it. An app bundle is a zip file anybody can download from the App Store and read. Fetch the hash from your own API alongside the rest of the signed-in user, the way you would a session token. Identifying users has the full recipe.
// Signing out on a personal device: the next conversation is nobody's yet.
support.identity = nil
// Signing out on a shared device: forget this conversation entirely.
await support.reset()Signing straight in as somebody else needs neither: a conversation belongs to whoever opened it, so when the identity changes from one user to another, the first user's conversation leaves the device.
What it does about a phone
- It asks every five seconds rather than holding a request open. While the chat is closed it drops to every thirty seconds, so the unread badge stays honest, and in the background it stops entirely.
- A message sent twice is stored once. Every send carries an id, so the SDK can retry through a tunnel without duplicates. Opening a conversation is the one exception — a start whose answer was lost is shown as a failed send, and retrying it is the visitor's decision.
- Unsent messages survive the app being killed. They go out on the next launch, in the order they were written.
- A reply is not emailed to somebody who is reading it. While the chat is on screen the SDK says so. Close it and the same reply is emailed as well, as long as the visitor gave an address.
What it does not do yet
- Push notifications. Nothing arrives while the app is closed. Ask for an email address — require_email on the widget settings screen — and a reply written while the visitor is away reaches them there.
- Attachments from the visitor. No client can send one yet. Files an agent attaches are listed by name.
- One conversation across two devices. The token belongs to one installation, so a reinstall starts a new conversation.
Checklist
- One HelpwingSupport, owned by the app. A @StateObject at the App, not one per screen, where every navigation would start over.
- The hash comes from your server. Everything in the bundle is public, including anything in Info.plist.
- Ask for an email address. Until push exists, it is the only way to reach somebody who has closed the app.