<!-- AI agents: this is the Markdown version of an AppNorth documentation page. Index: https://www.appnorth.app/llms.txt -->

# Support chat

Use `AppNorthChatView` for a direct support entry, or keep the floating chat button in `AppNorthView`. The project must enable chat in the dashboard.

## Open the conversation {#open-chat}

Chat modes are `disabled`, `identified`, and `anyone`. Install a [signed credential](/docs/verified-identity) before opening identified chat. Present the view using your app's navigation and dismissal controls:

```swift title="SupportEntry.swift"
import SwiftUI
import AppNorth

struct SupportEntry: View {
    let client: AppNorthClient
    @State private var isPresented = false

    var body: some View {
        Button("Support") { isPresented = true }
            .sheet(isPresented: $isPresented) {
                NavigationStack {
                    AppNorthChatView(client: client)
                        .navigationTitle("Support")
                        .toolbar {
                            ToolbarItem(placement: .confirmationAction) {
                                Button("Done") { isPresented = false }
                            }
                        }
                }
            }
    }
}
```

The view polls every five seconds while visible and active, stops when dismissed or backgrounded, and restarts on return. There is no push registration API in this SDK. [Paywall cards](/docs/paywall-cards) need a retained paywall controller passed into the view.

## Show an unread indicator {#unread}

Call `chatStatus()` when your entry appears or the app returns to the foreground. `available` controls whether the entry is usable; `hasUnread` indicates a team reply since the last read.

```swift title="SupportBadge.swift"
import AppNorth

func hasUnreadSupport(client: AppNorthClient) async throws -> Bool {
    let status = try await client.chatStatus()
    return status.available && status.hasUnread
}
```

`chatMessages(after:)` reads the conversation and marks team replies as read. Use `chatStatus()` for a badge so merely checking the badge does not consume unread state. A failed status request is unknown state, not proof that there are no unread messages.

## Send and retry {#send}

The built-in composer accepts up to `AppNorthChatModel.maximumLength` characters (2,000). Failed messages stay in memory and can be tapped to retry with their original idempotency key. They are not a durable offline queue.

For your own composer, use `AppNorthChatModel.send(_:)` and `retry(_:)`, or call `sendChatMessage(_:idempotencyKey:)` directly. Retain a UUID for the same message across retries. `send(_:)` returning `true` means delivery was queued, not that the server accepted it.

A custom `AppNorthChatModel` owner must call `stopPolling()` when its screen is hidden or the app becomes inactive. Dismiss chat and discard its model on logout or account change; then set the new identity before recreating it.

## Verify {#verify}

1. Send a test message, reply from the dashboard, and confirm it arrives while the conversation is open.
2. Leave chat, send another team reply, and check the unread indicator. Open chat and check that the indicator clears on the next status read.
3. Interrupt connectivity, send, reconnect, and tap the failed message. Confirm only one copy arrives.
4. Background the app and inspect network activity: the built-in chat view should stop polling.

## Ask for an App Store review {#review-requests}

Owners and admins can choose **Ask for a review** in an iOS conversation, including after resolving it. AppNorth sends a `review_request` action card with fixed English or Spanish copy, chosen from the conversation locale (English fallback). The SDK includes the app locale when starting a new chat; older conversations without a locale use English. It does not need RevenueCat or a paywall controller. Each conversation can contain one review request; retries, reopening, and concurrent manual/automatic sends reuse that card.

In **App settings → General**, enable **Automatic review requests** to send the card when the team selects **Resolve · positive experience**. The setting is off by default. Positive sentiment is an explicit team assessment, not an automatic analysis of the user's messages. Ordinary resolution and neutral or negative resolution do not trigger a request. The status change and automatic card creation share a tenant-scoped database transaction.

On iOS 16+, the built-in `AppNorthChatView` renders the card and calls `AppStore.requestReview(in:)` on tap using the window scene containing the chat. A card can request StoreKit once in that view; a recorded tap also disables it after reload. The server keeps the latest eligible conversation readable after resolution so its request can arrive. Starting a new support conversation still creates a new thread.

Apple decides whether its prompt appears. The card offers no incentive and asks for no particular rating. A card tap is **not** evidence that the prompt appeared, that a rating was selected, or that a review was published. Apple recommends avoiding review requests tied directly to a button; this chat-card flow follows the explicit tap interaction and does not guarantee a visible result. See [Apple's guidance on requesting reviews](https://developer.apple.com/documentation/storekit/requesting-app-store-reviews).

### Activity and custom chat UIs

For review requests, `openedAt` records the first card display and `tappedAt` records the first button tap. The inbox shows these as card activity. Offer cards retain their existing opened-on-tap semantics.

A custom UI should read `AppNorthChatMessage.actionCard`, render supported `kind` values, and leave unknown kinds disabled. These methods report activity for the current chat identity:

```swift title="ReviewCardActivity.swift"
try await client.markActionCardOpened(message.id) // when the review card appears
try await client.markActionCardTapped(message.id) // when its button is tapped
```

The methods use separate stable idempotency keys. They call `POST /api/v1/chat/cards/opened` and `POST /api/v1/chat/cards/tapped` with `{ "messageId": "…" }`; the server checks the project, chat identity, and ownership of that message. These methods only report activity: a custom UI is responsible for invoking StoreKit in its own scene. Do not infer a completed review from either event.

## Push notifications for replies

This opt-in API is available in the SDK source described here. The earlier note
about no push registration applies to older SDK builds.

Push is opt-in. Add the **Push Notifications** capability to your iOS target, request
notification permission in your app, and call `registerForRemoteNotifications()`
yourself. AppNorth does not swizzle delegates, request permission, or register with
APNs automatically.

In **Settings → Apps → your app → Integrations → Chat push notifications**, upload
an APNs `.p8` signing key and enter its key ID, Apple team ID, bundle ID and
environment. Use **Sandbox** for development builds and **Production** for
TestFlight and App Store builds. These are APNs credentials, separate from your
App Store Connect integration. Only workspace owners/admins can manage them. The
key is encrypted with the same versioned AES-GCM keyring as App Store Connect,
bound to the organization and app, and is never returned to the browser.

Configure the optional convenience facade with the same client used by your chat:

```swift
import AppNorth
import UIKit

// On the main actor, after creating your AppNorthClient:
AppNorth.configurePush(
    client: client,
    environment: .production,
    badgeHandler: { badge in
        // AppNorth uses 0 or 1: whether chat has unread replies.
        UIApplication.shared.applicationIconBadgeNumber = badge
    },
    routeHandler: { link in
        if link.kind == .chat {
            // Set your navigation state to present AppNorthChatView(client: client).
        }
    }
)
```

Forward the token from your application delegate. Handle errors and retry; the SDK
surfaces registration failures instead of silently caching a token that was not
registered.

```swift
func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    Task { @MainActor in
        do {
            try await AppNorth.registerPushToken(deviceToken)
        } catch {
            // Keep the token locally and retry when connectivity returns.
        }
    }
}
```

The instance API is also available:
`try await client.registerPushToken(token, environment: .sandbox)` and
`try await client.unregisterPushToken(token, environment: .sandbox)`. Both use the
same verified identity or anonymous installation token as chat. Register again
after changing identity and when APNs returns a new token. Before logout, await
`AppNorth.unregisterPushToken(token)` **before** clearing the client's identity;
retry if it fails. Then call `AppNorth.resetPush()` to clear the local badge callback to zero and release callbacks.
Resetting the facade alone does not unregister server devices. Tokens are scoped
to an app and environment; reassignment cancels queued pushes for the old account.

Forward `response.notification.request.content.userInfo` from your existing
`UNUserNotificationCenterDelegate` tap callback to
`AppNorth.handleNotificationTap(_:)` on the main actor. It returns `true` only for
this configured app's chat notification and invokes your route handler. Always
call the system completion handler, including when the notification belongs to
another feature. The payload routes to `appnorth://appnorth/chat/current`;
`AppNorthDeepLinks.chat(scheme:)` also creates a chat link for your own URL scheme.
Neither path grants access to a conversation: the current chat identity still
controls what can be read.

Call `try await AppNorth.syncUnreadBadge()` when your app becomes active and after
reading through a custom UI. The built-in chat refresh also syncs after a successful
read. Failed requests preserve the last badge. The callback can combine chat's
0/1 state with other local badge sources. APNs payloads themselves set the app icon
badge to 0/1, so account for this if your app uses the badge for other features.

Team replies enqueue delivery in the same tenant transaction as the reply,
including replies from Discord. Internal notes do not send push. The worker sends
a generic notification, without conversation text, using HTTP/2 and ES256 JWTs.
APNs is given a 24-hour expiration for offline delivery. It recalculates unread state before sending and replaces an already-read alert
with a badge-only update. Reading or resolving a chat also queues badge updates
for its registered devices. APNs delivery and badge updates remain best effort;
foreground status sync reconciles the SDK's badge callback.

Self-hosted deployments must apply migration `0038_apns`, configure
`APPNORTH_CREDENTIALS_KEY` and run the existing cron service. Its `push` task calls
`GET /api/internal/push` every minute with `Authorization: Bearer $CRON_SECRET`.
`node scripts/cron.mjs push` runs one pass. Transport errors, HTTP 429 and 5xx retry
with exponential backoff from 15 minutes, up to eight attempts; permanent failures
stop. HTTP 410 and invalid-device errors remove stale registrations (a 410 older
than the latest registration does not remove the renewed token). A worker crash
after APNs accepts a request can cause a repeated delivery; the outbox provides
at-least-once delivery, not exactly-once guarantees.

Disconnecting removes credentials, devices and their queued deliveries. Changing
the bundle ID also removes registered devices; register them again for the new
app. Completed queue records are cleaned after seven days, tokens after 180 days
without registration, and pending notifications older than seven days are discarded.

## Photos in chat

The native iOS composer includes a **PhotosPicker** button. Select up to **three
photos per message**, preview or remove them, then send them with or without text.
The picker downsamples to a maximum edge of **2048 pixels** and encodes a JPEG on
the device before uploading. Uploads are limited to **5 MB per image**. The hub
accepts JPEG, PNG and WebP; it verifies the decoded format, rejects animated
images, resizes if needed and re-encodes a JPEG without EXIF/GPS/XMP metadata.
Photos selected from an iPhone's HEIC library are converted by the native picker.

The dashboard chat composer also has **Attach photos**, with the same count and
size limits. Photos are part of the message, visible in the SDK and inbox; they
are separate from a conversation's internal file attachments. Internal notes do
not accept photo tokens. These limits apply equally on Free and paid plans;
normal chat identity and conversation-access rules still apply.

For a custom chat UI, upload an image, keep the returned token and include it in
the message. `AppNorthChatImageUpload` contains `imageToken` and `image`.
`AppNorthChatImage` has `id`, `url`, `width` and `height`. The optional
`AppNorthChatMessage.images` and `AppNorthChatEntry.images` expose received photos.

```swift
// jpegData: your resized JPEG, at most 5 MB. Custom UIs prepare their own pixels.
let photo = try await client.uploadChatImage(jpegData, contentType: "image/jpeg")
let key = UUID().uuidString
let message = try await client.sendChatMessage(
    "Así aparece en mi iPhone",
    imageTokens: [photo.imageToken],
    idempotencyKey: key
)
// Retry with the same body, imageTokens and key after a network failure.
```

`AppNorthChatModel.send(_:images:)` accepts completed uploads for optimistic
sending and retains them on retry. Copy overrides for the native photo controls
are `attachPhotos`, `removePhoto`, `attachedPhoto` and `photoLimits` on
`AppNorthCopy`; they are bundled in all supported SDK languages.

The SDK uses `POST /api/v1/chat/images?app_id=...&voter_token=...` twice: first with
`{contentType, byteSize}` to obtain `{uploadUrl, uploadToken}`, then, after a PUT to
the signed URL, with `{uploadToken}` to obtain `{imageToken, image}`. The final
message includes `imageTokens`. Identity headers go only to the hub, never to R2.
Upload URLs expire after **10 minutes**; completed photo tokens are valid for
**24 hours** and are bound to the app and reporter. Team tokens are additionally
bound to the team member and conversation. Private image read URLs expire after
**10 minutes**; reload the thread to refresh them. The SDK polls the full thread
while it contains photos to refresh those URLs.

### Self-hosted photo storage

Apply migration `0040_chat_images`, install the updated web dependencies, and
configure the existing `S3_*` storage settings and `APPNORTH_CREDENTIALS_KEY`.
The same private R2/S3 bucket used for screenshots and attachments is reused.
Configure bucket CORS to allow **PUT** from the exact dashboard origin with the
`Content-Type` header. The application CSP permits connections only to the
configured storage origin in addition to its own origin; no public bucket is
required.

Add an object lifecycle rule that expires **only the `chat-image-staging/`
prefix after two days**. It contains raw uploads and sanitized previews, including
abandoned selections. Sending copies sanitized pixels to the conversation's
private path; the pending-upload URL cannot overwrite a published photo. Do not
apply this short expiry to `organizations/`. Sent photos follow the existing
workspace media-retention job, participant erasure and account erasure. As with
other media, an erasure can be retried when storage is unavailable.
