# AppNorth > Native feedback and support chat for iOS apps, App Store tools and a remote MCP server. Every page below serves Markdown. Full documentation: https://www.appnorth.app/llms-full.txt ## Tasks - [Add AppNorth to an iOS app](https://www.appnorth.app/docs/get-started.md#setup-prompt) - [Configure the SDK manually](https://www.appnorth.app/docs/get-started.md#manual-setup) - [Verify the first support message](https://www.appnorth.app/docs/get-started.md#first-report) - [Understand the SDK, hub and MCP](https://www.appnorth.app/docs/overview.md#how-it-fits) ## Get started - [AppNorth overview](https://www.appnorth.app/docs/overview.md): Native feedback, support chat and App Store tools for your iOS apps. - [Get started](https://www.appnorth.app/docs/get-started.md): Add AppNorth to your iOS app and verify your first support message. ## iOS SDK - [Install the SDK](https://www.appnorth.app/docs/install.md): Add the Swift package, configure a client, and check your first request. - [Community view](https://www.appnorth.app/docs/community-view.md): Present boards, ideas, votes, and comments with the native SwiftUI screen. - [Build a custom UI](https://www.appnorth.app/docs/custom-ui.md): Use the client directly for paginated ideas, participation policies, and writes. - [Support chat](https://www.appnorth.app/docs/support-chat.md): Open the native conversation, show unread state, and manage retries. - [Paywall cards](https://www.appnorth.app/docs/paywall-cards.md): Let support send RevenueCat paywalls that open above the native chat. - [Private feedback](https://www.appnorth.app/docs/private-feedback.md): Submit private reports with consented context and read the author’s replies. - [Verified identity](https://www.appnorth.app/docs/verified-identity.md): Sign credentials on your backend and manage the iOS account lifecycle. - [Appearance](https://www.appnorth.app/docs/appearance.md): Adapt community and chat colors to your app’s light and dark themes. - [Language and copy](https://www.appnorth.app/docs/language.md): Choose the SDK language, override labels, and configure on-device idea translation. - [Offline writes and cache](https://www.appnorth.app/docs/offline-and-cache.md): Opt into queued writes and scoped read caching without mixing accounts. - [Deep links](https://www.appnorth.app/docs/deep-links.md): Create and route links to community ideas, private reports, and support chat. - [UIKit integration](https://www.appnorth.app/docs/uikit.md): Present the native SwiftUI community and chat screens from UIKit. ## Dashboard - [Feedback inbox](https://www.appnorth.app/docs/feedback-inbox.md): Read private feedback, reply to support conversations and organize community moderation. - [Roadmap and changelog](https://www.appnorth.app/docs/roadmap-and-changelog.md): Publish your roadmap and changelog and configure the hosted community. - [Explore](https://www.appnorth.app/docs/explore.md): Research iOS apps, studios, creative feeds and chart observations with clearly labelled estimates. - [AppTracker](https://www.appnorth.app/docs/apptracker.md): Review recorded changes for watched market apps, with clear event and history limits. ## App Store - [Reviews](https://www.appnorth.app/docs/reviews.md): Read App Store reviews and prepare and publish replies from the hub. - [Keywords](https://www.appnorth.app/docs/keywords.md): Track App Store keywords and explore candidates for your app. - [Keyword Explorer](https://www.appnorth.app/docs/keyword-explorer.md): Research App Store search results by country before choosing terms to track in an app. - [Metadata](https://www.appnorth.app/docs/metadata.md): Edit localized App Store metadata and review suggestions. - [Price localization](https://www.appnorth.app/docs/price-localization.md): Preview prices by territory and follow queued App Store price updates. ## Integrations - [App Store Connect](https://www.appnorth.app/docs/app-store-connect.md): Connect your App Store Connect team key to import and manage your apps. - [RevenueCat](https://www.appnorth.app/docs/revenuecat.md): Connect RevenueCat to view revenue metrics alongside your app. - [Telegram](https://www.appnorth.app/docs/telegram.md): Reply to your iOS app’s chats and reports from Telegram topics. - [Discord](https://www.appnorth.app/docs/discord.md): Connect an app to a Discord channel and reply to in-app conversations from threads. - [Servidor MCP](https://www.appnorth.app/docs/mcp.md): Conecta tu IA a tus apps y al mercado, con permisos, previsualizaciones y créditos por organización. ## Reference - [Swift API](https://www.appnorth.app/docs/swift-api.md): Public Swift types, members, and stable anchors for the iOS SDK. - [Errors](https://www.appnorth.app/docs/errors.md): Understand SDK errors and server responses. - [Limits](https://www.appnorth.app/docs/limits.md): Check input limits, request sizes and rate limits. - [Privacy](https://www.appnorth.app/docs/privacy.md): Review the SDK privacy manifest and your responsibilities for collected data. - [Troubleshooting](https://www.appnorth.app/docs/troubleshooting.md): Diagnose configuration, identity, chat and connectivity issues. - [Versions](https://www.appnorth.app/docs/versions.md): Review supported SDK versions and compatibility. --- # AppNorth overview Bring feedback, support conversations and App Store work into one hub for your iOS apps. Add the Swift SDK to your app, reply from your inbox, and connect your App Store tools when you need them. ## Explore the documentation {#explore} :::cards - [Get started](/docs/get-started) Install the SDK, choose an entry point and verify your first support message. - [iOS SDK](/docs/install) Add community views, support chat and private feedback to your app. - [Feedback inbox](/docs/feedback-inbox) Read reports, reply to conversations and organize feedback. - [App Store tools](/docs/reviews) Work with reviews, keywords, localized metadata and prices. - [Explore](/docs/explore) Browse market apps, creative feeds and studios, then follow watchlist activity in AppTracker. - [Keyword Explorer](/docs/keyword-explorer) Research public App Store search results before tracking terms in an app. - [RevenueCat](/docs/revenuecat) Connect revenue metrics to your app in the hub. - [MCP server](/docs/mcp) Give your coding agent access to App Store tools with an API token. ::: Pages marked **soon** are documentation drafts. Start with the complete [Get started guide](/docs/get-started) for integration instructions. ## How AppNorth fits together {#how-it-fits} Your iOS app owns the entry point, navigation and user authentication. The Swift package provides native SwiftUI views and a client for support chat, private reports and a community of boards, ideas, votes and comments. UIKit apps can present the same views through `UIHostingController`. The hub stores feedback and conversations and gives your team an inbox. Each project has a public App ID. The SDK uses that identifier together with the AppNorth server URL and an allowed origin. An App ID is safe to ship in your app; the identity signing secret is not. Chat access is configured per project: disabled, verified users or anyone. The default requires verified identity. For signed-in users, your backend issues a short-lived identity credential; the app passes it to the client and clears it on sign-out. App Store Connect connects your team’s apps to reviews and replies, keyword tracking, metadata editing and price localization. RevenueCat is a separate connection for revenue metrics. These connections are managed in the hub; they are not required to display the SDK community view. The remote MCP server lives at `/api/v1/mcp`. Agents authenticate with API tokens managed in Settings. It exposes App Store tools and prompts; it is separate from the SDK’s public App ID and identity credentials. ## Dashboard utilities {#dashboard} The [dashboard](/dashboard) sidebar opens **What's new**, **Notifications** and **Request a feature** as modals. ### What's new {#whats-new} What's new lists dated AppNorth product updates with links to the relevant documentation. These are product announcements, separate from your apps' activity notifications. ### Notifications {#notifications} Notifications lists unread inbox conversations and available signals for reviews, keyword drops, price jobs, revenue and [AppTracker](/docs/apptracker#notifications). These come from your accessible apps and collected market activity, subject to workspace access. Some aggregate signals have no source timestamp, so no date is displayed for them. Use the all/unread view and type filter to narrow the list. Open an item to visit its source, or mark it read in the modal. This read state is stored in this browser's local storage for your workspace and user, with an in-memory fallback if storage is unavailable. It does not synchronize to another browser or, by itself, mark an inbox conversation read. Choose what appears in [Settings → Notifications](/dashboard/settings/notifications): turn off workspace signals (inbox, reviews, keywords, prices, revenue) or individual AppTracker events, or pause every AppTracker alert at once. To silence one watched app, use its bell in [AppTracker](/docs/apptracker#notifications). These preferences filter both the modal and the sidebar badge. They are also saved in this browser for your workspace and user, and they do not change the team's email notifications. ### Request a feature {#request-feature} Choose a new feature, improvement or problem. Enter a required title of up to 180 characters, optional details of up to 4,000 characters and an optional importance level. You can click, drop or paste one PNG, JPEG or WebP screenshot up to 5 MB. The browser resizes it to at most 1,600 pixels on its longest side and compresses it to fit the 600 KiB upload limit; an image that cannot be prepared is rejected. Live submission requires an active subscription and allows up to five attempts per user per hour. The request includes your text, request type, importance, optional screenshot, browser viewport and interface language. The server adds your signed-in name and email, workspace ID and name, and plan. It records the dashboard as the page URL, rather than capturing the current page automatically. Requests are saved as feedback in the installation's configured destination project, selected by `APPNORTH_FEEDBACK_APP_ID`. Text and request metadata are persisted in the database; the screenshot goes to configured S3-compatible object storage. If the text is saved but the screenshot upload fails, the modal says so. When that destination is not configured or cannot be resolved, **Send by email** opens a draft to `hola@appnorth.app` with the title, type, details and importance. You send it from your email client and attach the screenshot manually. In demo mode, the form validates the input and shows a demo confirmation without delivering or storing a request. ## Choose your starting point {#starting-point} Use [Get started](/docs/get-started) to integrate the SDK, or copy its [setup prompt](/docs/get-started#setup-prompt) into your coding agent. You need an iOS 16 or later app and your project’s public App ID. Every page has **Copy Markdown**, **View Markdown** and **Copy link for agent** controls. The [agent index](/llms.txt) lists published guides and task links. [All published documentation](/llms-full.txt) is also available in one text file. Draft pages stay out of both indexes until their content is ready. --- # Get started Add AppNorth to your iOS app, send a test support message and check it in your inbox. Use the setup prompt with a coding agent or follow the manual steps below. ## Requirements {#requirements} - An iOS app targeting iOS 16 or later. - Xcode with Swift Package Manager. The SDK is distributed as a compiled XCFramework with the `AppNorth` product. - An AppNorth account and permission to create or configure a project. - Your public App ID, server base URL and an allowed origin. Your app can use SwiftUI or host the SwiftUI views with UIKit. App Store Connect and RevenueCat credentials are separate from SDK configuration. ## Create your project {#create-project} 1. [Sign in](/login) and complete onboarding to create your iOS app project. You can select an App Store app or enter your app’s name. 2. Open the project’s SDK setup screen. For an existing project, open its SDK setup from the dashboard. 3. Copy the public App ID and the installation configuration. **Copy setup prompt** supplies the project name and App ID to your coding agent. The public App ID identifies the project receiving feedback. Use the public ID from SDK setup, not the internal project ID in the dashboard URL. Onboarding creates an allowed `localhost` origin. For an existing project, check its allowed domains before reusing the example below. `origin` is a required SDK configuration value sent on writes, even though your product is a native iOS app. It is an anti-abuse signal, not proof of user identity. :::callout Use the AppNorth server base URL for `apiURL`, such as `https://www.appnorth.app`, without an `/api/v1` suffix. `origin` is a separate value. If you run the server locally, a physical iPhone needs a reachable server address. ::: ## Set up with a coding agent {#agent-setup} Open your iOS project in your coding agent and paste the setup prompt. The dashboard version already includes your public App ID. The public version below asks for it before configuring the SDK. The agent should ask where the support or community entry belongs, follow your app’s dependency injection pattern, build the app and verify a real test message. If you use accounts, it also needs an authenticated backend to issue verified identity credentials. ### Setup prompt {#setup-prompt} Copy this prompt into your coding agent. It asks for your App ID; the prompt copied during onboarding fills it in for you. ```text title="AppNorth setup prompt" {collapsed} Integrate AppNorth into my iOS app "ask the developer". Public App ID: "ask the developer". This is a public identifier, not a secret. First read https://www.appnorth.app/llms.txt and https://www.appnorth.app/docs/get-started.md. Use https://www.appnorth.app/docs/overview.md for product context. If the App ID says "ask the developer", ask for the public App ID from the project’s SDK setup screen before editing configuration. Never use that placeholder in a shipped app. 1. Inspect the app's architecture, deployment target and dependency setup. AppNorth requires iOS 16 or newer. Before changing navigation, ask me where the feedback/community entry should go. 2. Add the Swift Package https://github.com/victorgxn/appnorth-ios, from version 0.2.0, and link the AppNorth product to the app target. It distributes a compiled XCFramework. 3. Create and retain one AppNorthClient at launch, following the app's dependency injection pattern. Use this documented configuration and SwiftUI entry: import AppNorth import SwiftUI let configuration = try AppNorthConfiguration( apiURL: URL(string: "https://www.appnorth.app")!, appId: "ask the developer", origin: URL(string: "http://localhost")! ) let client = AppNorthClient(configuration: configuration) struct FeedbackScreen: View { var body: some View { AppNorthView(client: client) } } The current SDK requires origin and sends it on writes. onboarding-created projects allow localhost; verify the allowed origins for an existing project; do not invent a website or remove the required initializer argument. apiURL is the AppNorth server base URL, not a /api/v1 endpoint. If it points to localhost, a physical iPhone needs a reachable server URL. 4. Add the entry where I chose. Use AppNorthView(client: client) for ideas and feedback, or AppNorthChatView(client: client) for the support conversation. Use UIHostingController if the app uses UIKit. 5. If the app has accounts, obtain a short-lived v1 identity credential from its authenticated backend and use AppNorthIdentityCredential(email:expiresAt:signature:) with client.setIdentityCredential(_:). Call client.clearIdentityCredential() on sign-out. Never embed the project signing secret or signing logic in the iOS app. Ask for the backend integration if it is missing; do not fabricate signatures. Chat defaults to requiring verified identity; if there are no accounts, ask the owner to enable anonymous chat in AppNorth before testing chat. 6. Build the app and resolve compilation issues. Open the new entry and send a clearly labelled test support message (or a private report using the documented client.createFeedback API). Confirm it appears in AppNorth; creating a public idea alone does not complete the first-report check. Do not send private diagnostics without explicit consent. 7. Add this reference to AGENTS.md (create it if absent), and to CLAUDE.md if it exists: "AppNorth integration docs: https://www.appnorth.app/llms.txt — start at https://www.appnorth.app/docs/get-started.md#setup-prompt." Preserve all existing instructions. 8. Summarize the files changed, where the entry lives and whether the test message reached AppNorth. Do not claim success until verified. ``` ## Set up manually {#manual-setup} ### Add the package {#add-package} In Xcode, choose **File → Add Package Dependencies…** and enter: ```text title="Swift package URL" https://github.com/victorgxn/appnorth-ios ``` Choose version `0.2.0` or a compatible later version and link the **AppNorth** product to your app target. If your project manages dependencies in `Package.swift`, merge these entries into its existing package and target configuration: ```swift title="Package.swift" {collapsed} // Package.swift dependencies: [ .package(url: "https://github.com/victorgxn/appnorth-ios", from: "0.2.0") ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "AppNorth", package: "appnorth-ios") ]) ] ``` ### Configure a client {#configure-client} Create and retain a client using your public App ID. Replace `YOUR_PUBLIC_APP_ID` before building. The sample uses the hosted server and the `localhost` origin allowed by onboarding; verify the origin for your own project. ```swift title="FeedbackScreen.swift" import AppNorth import SwiftUI let configuration = try AppNorthConfiguration( apiURL: URL(string: "https://www.appnorth.app")!, appId: "YOUR_PUBLIC_APP_ID", origin: URL(string: "http://localhost")! ) let client = AppNorthClient(configuration: configuration) struct FeedbackScreen: View { var body: some View { AppNorthView(client: client) } } ``` The configuration initializer throws for invalid configuration. Handle that error at your app’s composition root. Keep the client in your existing state or dependency container instead of recreating it each time a view renders. ### Choose an entry point {#entry-point} Present `AppNorthView(client: client)` from a row, button or sheet in your app to show its community. For a dedicated support conversation, present: ```swift title="Support view" AppNorthChatView(client: client) ``` The community view can also show a floating chat entry when chat is enabled. To place the chat in UIKit, wrap the SwiftUI view in a `UIHostingController` and present it using your app’s navigation. ### Configure chat access and identity {#identity} Chat defaults to requiring verified identity. If your app has accounts, have its authenticated backend issue a short-lived v1 credential. Pass the backend response to the SDK: ```swift title="After sign-in" let credential = try AppNorthIdentityCredential( email: response.email, expiresAt: response.expiresAt, signature: response.signature ) client.setIdentityCredential(credential) ``` Here, `response` is your backend’s credential response, not an SDK-provided object. Keep the project signing secret and signing logic on the backend. Clear the credential with `client.clearIdentityCredential()` on sign-out. Do not fabricate a signature to get past verification. If your app has no accounts, enable anonymous chat in the project’s chat settings before testing. When chat is disabled, adding a view alone does not enable it. ## Verify your first report {#first-report} 1. Build and run the app with the new support entry. 2. Open the conversation and send a clearly labelled test support message. 3. Open the same project in AppNorth and check that the message appears in the feedback inbox. 4. Reply from the inbox and keep the app active with chat open to check the reply. Chat refreshes while its view is visible and the app is in the foreground. A failed message can be retried from the conversation. A public idea alone does not satisfy onboarding’s first-report check; use a support message or a private report through `client.createFeedback`. :::callout Only include diagnostics or screenshots with explicit consent and after removing sensitive information. The SDK does not collect or sanitize those attachments automatically. ::: ### If the message does not arrive {#check-first} - Check that the public App ID belongs to the project you are viewing in the hub. - Check the server base URL, network access and allowed origin. - Check that chat is enabled and the selected mode matches your identity setup. - For verified chat, check that the credential has not expired and was issued by your backend for this project. - Keep chat open in the foreground when checking for a reply. ## Next steps {#next-steps} The sidebar includes upcoming guides for community views, private feedback, identity, appearance and the full Swift API. They are marked **soon** while their documentation is being completed. Use the [overview](/docs) to understand how the SDK, App Store integrations and MCP fit together. Keep the [agent index](/llms.txt) in your project’s agent instructions so later integration work starts from the published documentation. --- # Install the SDK Add the `AppNorth` product to an iOS app targeting iOS 16 or later. SwiftUI screens work directly; UIKit apps can use a [hosting controller](/docs/uikit). ## Add the package {#add-package} In Xcode, choose **File → Add Package Dependencies…**, enter the URL below, and link **AppNorth** to your app target. Select version `0.2.0` or a compatible later version. Distribution uses a compiled XCFramework. ```text title="Swift package URL" https://github.com/victorgxn/appnorth-ios ``` For a project managed with a Swift package manifest, merge these entries into its existing dependency and target lists: ```swift title="Package.swift dependency entries" // Package.swift dependencies: [ .package(url: "https://github.com/victorgxn/appnorth-ios", from: "0.2.0") ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "AppNorth", package: "appnorth-ios") ]) ] ``` ## Configure once {#configure} Copy the public App ID, server base URL, and allowed origin from your project's SDK setup. Retain one client for the session and inject it into your screens. Configuration throws before making a request if the URLs or App ID are invalid. ```swift title="AppNorthSetup.swift" import Foundation import AppNorth func makeAppNorthClient(appID: String, allowedOrigin: URL) throws -> AppNorthClient { let configuration = try AppNorthConfiguration( apiURL: URL(string: "https://www.appnorth.app")!, appId: appID, origin: allowedOrigin ) return AppNorthClient(configuration: configuration) } ``` Use the server base URL without `/api/v1`; the client appends endpoint paths. `origin` contains only a scheme, host, and optional port. It must be allowed for your project. This is required for native writes too: it is an anti-abuse signal, not an identity credential. Use HTTPS in production. The App ID is public. Keep identity signing secrets, App Store Connect keys, and RevenueCat secret API keys on your backend. For account-based actions, [install a signed identity credential](/docs/verified-identity) before opening the screen. ## Open a screen {#open-screen} Pass the retained client into your existing navigation or sheet: ```swift title="CommunityScreen.swift" import SwiftUI import AppNorth struct CommunityScreen: View { let client: AppNorthClient var body: some View { AppNorthView(client: client) } } ``` `AppNorthView` owns its navigation stack. For a support-only entry, use [AppNorthChatView](/docs/support-chat). ## Verify {#verify} 1. Build and run your app, then open the community screen. A project with no ideas should show an empty state, not a connection error. 2. Load `try await client.boards()` and confirm the returned boards belong to your project. 3. Enable an appropriate submission or chat policy in the dashboard, send a clearly labeled test message, and find it in the same project's inbox. 4. If reads work but writes fail, check the allowed origin. On a physical device, a local development server must use an address reachable from that device. --- # Community view Use `AppNorthView` to show your project's boards and ideas, let people propose improvements, and open idea details with comments. ## Present the community {#present} Retain the [configured client](/docs/install#configure) outside the view body. The SDK owns the community navigation stack; a sheet is a convenient entry from an existing settings screen. ```swift title="CommunityEntry.swift" import SwiftUI import AppNorth struct CommunityEntry: View { let client: AppNorthClient @State private var isPresented = false var body: some View { Button("Ideas and roadmap") { isPresented = true } .sheet(isPresented: $isPresented) { AppNorthView(client: client) } } } ``` The screen loads boards and ideas, offers board and sort controls, supports pull to refresh, and opens a creation sheet. Votes update optimistically and revert on failure. The built-in list loads the first page; use the [client API](/docs/custom-ui#pagination) for your own paginated list. ## Match your community policies {#policies} Each board supplies independent `submission`, `vote`, and `comment` policies: `anyone`, `identified`, or `disabled`. Install a [verified identity](/docs/verified-identity) for identified actions. The SDK uses these policies for controls; the server also enforces them. Open and closed filtering follows `AppNorthBoardStatus.isFinal`, not a hard-coded status name. Disable the filter when you want one combined list: ```swift title="CombinedCommunity.swift" import SwiftUI import AppNorth struct CombinedCommunity: View { let client: AppNorthClient var body: some View { AppNorthView( client: client, uiConfiguration: AppNorthUIConfiguration( showsOpenClosedFilter: false, upvoteIcon: .thumbsUp, showsChatButton: false ) ) } } ``` `showsChatButton` defaults to `true`; the floating entry still depends on chat availability. Branding comes from `widgetConfig()`. A failed branding request does not prevent the community from loading. ## Keep pending ideas honest {#moderation} Creation responses include `moderated`. Treat a moderated submission as awaiting review rather than promising it is publicly visible. Board labels, status colors, counts, and policies come from the server. ## Verify {#verify} 1. Open a board and switch sorting and open/closed filtering. Confirm closed ideas follow the board's final statuses. 2. Submit a test idea, vote, remove the vote, and add a comment under the configured policies. 3. Try an identified action before and after sign-in, then dismiss and recreate the community screen after logout. 4. Check a moderated board and an empty board; neither should display invented activity or counts. --- # Build a custom UI Use `AppNorthClient` when your app needs its own list, search, or composer. It handles HTTP encoding, project identifiers, identity headers, and response decoding. Your UI owns loading, error, retry, and pagination states. ## Load boards and ideas {#read} Read boards to obtain their slugs, statuses, and participation policies. Search uses `query`; status filtering uses the server's status slug. ```swift title="CommunityQueries.swift" import AppNorth func searchIdeas(client: AppNorthClient, board: String, text: String) async throws -> AppNorthPage { try await client.ideas(board: board, query: text, sort: .recent, limit: 20) } func comments(client: AppNorthClient, idea: AppNorthIdea) async throws -> AppNorthPage { try await client.comments(for: idea.id) } ``` `idea(_:)`, comments, votes, and comment creation accept an idea ID or slug. Use the returned `AppNorthIdea` rather than constructing response models: most response types have no public memberwise initializer. ## Load the next page {#pagination} Pass `nextCursor` back unchanged. Stop when it is `nil`, and reset the cursor when filters or sorting change. The client clamps page sizes to 1–50, with a default of 20. ```swift title="NextIdeaPage.swift" import AppNorth func nextPage( client: AppNorthClient, board: String, previous: AppNorthPage ) async throws -> AppNorthPage? { guard let cursor = previous.nextCursor else { return nil } return try await client.ideas(board: board, sort: .recent, cursor: cursor) } ``` Use the same board, sort, search, and status on every page of one query. Discard responses from an old query or account after the user changes context. ## Submit and retry {#writes} Create one UUID per user intention and retain it until that operation succeeds or is abandoned. Pass that same key on retry; a new key can create another item. ```swift title="SubmitIdea.swift" import AppNorth enum CommunitySubmissionError: Error { case notAllowed } func submitIdea( client: AppNorthClient, board: AppNorthBoard, title: String, description: String, retryKey: String ) async throws -> AppNorthCreation { guard client.canParticipate(board.policies.submission) else { throw CommunitySubmissionError.notAllowed } return try await client.createIdea( board: board.slug, title: title, description: description, idempotencyKey: retryKey ) } ``` Handle `CommunitySubmissionError.notAllowed` with your app’s sign-in or disabled state. `canParticipate` checks local policy and expiry; only the server verifies the signature. `setVote(on:voted:)` expresses the desired state. Render its returned `voted` and `voteCount`, and use `applied` to distinguish a change from a repeated request. `createComment(on:body:authorName:idempotencyKey:)` returns a creation result with the same moderation flag as ideas. ## Handle failures {#errors} Catch `AppNorthError.server(status:code:fields:)` to distinguish identity failures, validation errors, and rate limits. Keep the entered text on failure. Do not automatically retry every 4xx response; renew an expired identity through your authenticated backend first. See [errors](/docs/errors) and [offline writes](/docs/offline-and-cache). ## Verify {#verify} 1. Load a board and an idea from its response, then retrieve that idea's comments. 2. Verify pagination using actual `nextCursor` values, including the final `nil` cursor. 3. Retry the same creation with the same key and unchanged payload; confirm only one item exists. 4. Test disabled and identified policies, moderated submissions, and a failed request without losing the draft. --- # 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. --- # Paywall cards A support agent can send an offer card in an iOS chat. The customer sees an optional message and a button that opens your app's RevenueCat paywall. AppNorth records the first tap, not a purchase. Your existing purchase flow owns the transaction. ## Add the optional helper {#helper} The AppNorth SDK does not depend on RevenueCat. Add `RevenueCat` and `RevenueCatUI` from purchases-ios **5.50 or later in the 5.x line**, then copy `packages/ios/Helpers/AppNorthRevenueCatPaywalls.swift` from the integration sources into your app target. This helper is separate source, not a type exported by the AppNorth binary. Configure `Purchases`, sign in to the correct RevenueCat account, and install the corresponding AppNorth identity credential before creating the helper. Retain it for the session rather than recreating it in `body`. ```swift title="OfferSession.swift" import AppNorth @MainActor final class OfferSession { let helper: AppNorthRevenueCatPaywalls var syncFailed = false init(client: AppNorthClient) { helper = AppNorthRevenueCatPaywalls(client: client) helper.onError = { [weak self] _ in self?.syncFailed = true } } func stop() { helper.stop() } } ``` This snippet requires the copied helper in the same target. Its optional `presenter:` closure returns the UIKit controller to present above. Supply it for multiwindow apps; the default searches the active scene's key window and top presented controller. ## Connect it to chat {#chat} Pass `helper.paywalls` to `AppNorthChatView` or to `AppNorthView` for its floating chat entry. The example below works with either the helper's controller or a controller you configure yourself: ```swift title="OffersChat.swift" import SwiftUI import AppNorth struct OffersChat: View { let client: AppNorthClient let paywalls: AppNorthPaywalls var body: some View { AppNorthChatView(client: client, paywalls: paywalls) } } ``` The helper loads all offerings at startup and on foreground, observes `customerInfoStream`, and presents `PaywallView` above chat. It dismisses on purchase completion, explicit close, or a restore with an active entitlement. Configure a usable close control in your paywall design and test returning to the conversation. Offerings without a published paywall are not presentable. A card becomes unavailable when its offering disappears or none of its products remain available. Consumables never count as owned; subscriptions use active product IDs, allowing an offering with other plans to remain available for a plan change. ## Report your own catalog {#manual} If your app already owns paywall presentation, retain an `AppNorthPaywalls` controller on the main actor. Pass real localized prices and ISO-8601 periods from your products, not invented display values. ```swift title="ConfigurePaywalls.swift" import AppNorth @MainActor func configurePaywalls( controller: AppNorthPaywalls, catalog: [AppNorthPaywall], availability: @escaping (String) async -> Bool, present: @escaping (String) -> Void ) async throws { try controller.setPaywalls(catalog) controller.availabilityProvider = availability controller.onPaywall = present try await controller.refreshPaywalls(invalidate: true) } ``` Call `refreshPaywalls(invalidate: true)` after purchases or customer state changes. Overlapping refreshes coalesce. A missing catalog entry, an unsupported card kind, a non-presentable offering, or a missing presentation callback keeps a card disabled. Snapshots allow 20 paywalls, 10 products per paywall, and 40 products total. Paywall IDs are 1–64 characters from `A-Z a-z 0-9 _ . -`; titles are nonempty and at most 60 UTF-16 units. Titles are team-only labels. Product prices are localized strings; periods and trials use forms such as `P1M` and `P7D`, with `P0D` for a one-time product. Invalid or oversized snapshots throw instead of silently dropping offerings. ## Metadata and receipts {#metadata} By default the helper reports device-derived `subscription_status`, `plan`, `renews_on` or `expires_on`, and `environment: sandbox` when applicable. Pass `syncsSubscriptionMetadata: false` to omit them. These are separate from signed identity properties and must not be used as trusted authorization claims. If several subscriptions are active, the displayed plan is the first product ID alphabetically; availability still checks all products. `onOfferOpened` receives the message ID and paywall ID once per card in the controller. Repeated taps may reopen the paywall, while server receipts count the first tap once. A failed receipt retries on the next tap or refresh while the controller lives. This callback does not confirm presentation or purchase completion. Cards arrive through chat polling; this SDK has no push delivery API. ## Change accounts safely {#account-changes} Call `helper.stop()`, close chat, and discard the helper **before** RevenueCat logout or an account switch. Update RevenueCat and AppNorth's credential, then create a new helper. Do not reuse the previous user's catalog or subscription metadata. The controller also invalidates availability when it detects an identity-scope change. ## Verify {#verify} 1. Open chat with the retained helper and confirm the device's actual offerings appear in the dashboard's **Send offer** flow. 2. Send a card, tap it, close the paywall, and confirm you return to chat. 3. Complete a sandbox purchase and verify availability refreshes from RevenueCat. Use RevenueCat to verify the transaction; an AppNorth open receipt measures only the tap. 4. Tap repeatedly and confirm one open receipt. Test a removed offering, a missing callback, and a failed receipt followed by reconnecting. 5. Switch accounts and verify neither the old catalog state nor subscription metadata is reused. --- # Private feedback Use `createFeedback` for a private problem report, suggestion, or comment. This is separate from public community ideas and the support chat conversation. Your app supplies the form and explicit consent for any diagnostics or capture. ## Submit a report {#submit} Supply a stable HTTP(S) URL representing the screen, a positive viewport size, and the user's message. `pageURL` is report context, not a custom-scheme deep link. Keep credentials and personal data out of that URL. ```swift title="PrivateReport.swift" import Foundation import AppNorth func submitReport( client: AppNorthClient, message: String, screenURL: URL, viewport: AppNorthViewport, retryKey: String ) async throws -> AppNorthFeedbackReceipt { let report = AppNorthCreateFeedback( type: .problem, message: message, pageURL: screenURL, pageTitle: "Settings", locale: Locale.current.identifier, viewport: viewport ) return try await client.createFeedback(report, idempotencyKey: retryKey) } ``` Use the current screen's dimensions for `viewport`. Messages must contain 3–5,000 characters after trimming. The receipt contains `id`, `correlationId`, `screenshotStored`, and `deduplicated`; check `screenshotStored` instead of assuming the attachment was retained. Reuse the key and unchanged payload only for retries of the same report. ## Attach context deliberately {#context} `AppNorthDeviceContext` accepts app name/version/build, model/type, and OS/version. It flattens whitespace and limits each string to 120 Swift characters; it does not remove arbitrary secrets. The host chooses and sanitizes the values. Diagnostics use `AppNorthFeedbackDiagnostic` with a category, optional level, message, timestamp, and JSON metadata. The server accepts at most 100 diagnostics and 1,000 characters per diagnostic message. Never forward raw authorization headers, cookies, passwords, tokens, or signing keys. ## Capture only after consent {#capture} Implement the capture and redaction protocols in your app, then call the helper. The SDK contains no automatic screenshot collector or consent UI. ```swift title="ConsentedCapture.swift" import AppNorth func reportScreenshot( consent: Bool, capture: any AppNorthScreenshotCapturing, redactor: any AppNorthScreenshotRedacting ) async -> String? { await captureAppNorthScreenshot( consent: consent, adapter: capture, redactor: redactor ) } ``` The helper returns `nil` if consent, either adapter, validation, or redaction fails. It also rejects unchanged redactor output. Valid output is a PNG, JPEG, or WebP base64 data URL of at most 2,800,000 UTF-8 bytes. A changed image is not proof that all sensitive content is hidden: preview the redaction in your own tests before attaching the result to `AppNorthCreateFeedback`. ## Read replies as the author {#replies} Set a verified identity **before creating** reports whose replies the user must retrieve. An optional contact email must match the signed email; anonymous reports are not reassigned after login. ```swift title="ReadReportReplies.swift" import AppNorth func reportReplies(client: AppNorthClient, reportID: String) async throws -> [AppNorthFeedbackReply] { try await client.feedbackThread(reportID).replies } ``` Retain the returned report ID using your account-scoped storage. `feedbackThread(_:)` requires signed identity and returns the report plus public staff replies. It excludes internal notes, contact details, diagnostics, and screenshots. Another user's or project's report returns not found. The SDK has no public report-list method. ## Verify {#verify} 1. Submit a test report and locate its receipt ID in the dashboard. 2. Reply publicly as staff and fetch the thread with the original identity. 3. Try another account and confirm it cannot read the report. 4. Test capture without consent, a throwing redactor, and unchanged redactor output; all should omit the screenshot. --- # Verified identity Use verified identity for identified community actions, account-based chat, and private report replies. Your authenticated backend decides which user to sign; the iOS app receives only a short-lived credential. ## Create the signing secret {#secret} An administrator creates the project's identity secret in SDK setup. Store that secret and the internal **Project ID** in your backend environment. The public **App ID** passed to `AppNorthConfiguration` is a different value. Never ship the secret or signing code in your app. A public App ID, an allowed `Origin`, and the anonymous voter token do not prove who a person is. ## Issue a credential {#sign} Authenticate the request using your existing backend session. Obtain the email from that session, not a caller-supplied email. Sign the exact UTF-8 payload below with HMAC-SHA256 and return a hexadecimal signature. `expiresAt` is integer Unix seconds, with a maximum lifetime of 24 hours; this example uses 15 minutes. ```javascript title="Authenticated backend identity handler" import { createHmac } from "node:crypto"; export function appNorthIdentity(authenticatedUser) { const projectId = process.env.APPNORTH_PROJECT_ID; const secret = process.env.APPNORTH_IDENTITY_SECRET; if (!projectId || !secret) throw new Error("Missing AppNorth identity configuration"); const email = authenticatedUser.email.trim().toLowerCase(); const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60; const payload = `v1\n${projectId}\n${expiresAt}\n${email}`; const signature = createHmac("sha256", secret).update(payload).digest("hex"); return { email, expiresAt, signature }; } ``` Return the credential over HTTPS from an authenticated endpoint with `Cache-Control: no-store`. Do not log the credential: it acts as a bearer credential until it expires. ## Install it in the client {#identify} Decode your backend response and install it after sign-in and whenever your backend renews it: ```swift title="AppNorthIdentity.swift" import AppNorth struct IdentityResponse: Decodable { let email: String let expiresAt: Int64 let signature: String } func applyIdentity(_ response: IdentityResponse, to client: AppNorthClient) throws { let credential = try AppNorthIdentityCredential( email: response.email, expiresAt: response.expiresAt, signature: response.signature ) client.setIdentityCredential(credential) } ``` The SDK keeps credentials in memory and sends them in `X-AppNorth-Identity-*` headers, never in the query or JSON body. `hasValidIdentity(at:)` and `isValid(at:)` check local expiry only; they do not verify HMAC signatures. An expired installed credential blocks requests locally with `INVALID_IDENTITY`. Renew through your backend rather than silently downgrading the action to anonymous. ## Add signed properties {#properties} For properties, sign `v2\n\n\n\n`. Canonical properties are a JSON object serialized with keys sorted alphabetically. Return the same values to the app and pass them using `AppNorthIdentityProperty.string`, `.number`, or `.boolean` in the credential initializer. There may be at most 20 keys. Keys start with a letter and contain only letters, digits, `_`, `.`, or `-`, up to 64 characters; `constructor` is forbidden. Values are strings of at most 200 characters, finite numbers, or booleans. Objects, arrays, and null are rejected, and the serialized object must fit in 2 KiB. Omitting properties uses v1; an empty properties object still uses v2. Changing properties on the client invalidates the signature. ## End or change the session {#logout} Before switching accounts, dismiss SDK screens, stop custom polling, stop and discard any [RevenueCat helper](/docs/paywall-cards#account-changes), and cancel host-owned requests. Then clear the credential: ```swift title="ClearIdentity.swift" import AppNorth func clearAppNorthIdentity(client: AppNorthClient) { client.clearIdentityCredential() } ``` `clearIdentityCredential()` clears only the credential. Your app must discard account-specific view models and clear or separate its own [read cache and offline storage](/docs/offline-and-cache#account-changes). Install the next identity before recreating screens. Do not persist credentials as long-lived sessions. Secret rotation invalidates existing signatures and hosted-roadmap sessions. There is no per-person revocation list in this contract; stop issuing credentials to that account and use short expiry, or rotate the project secret when immediate project-wide revocation is needed. ## Verify {#verify} 1. Submit an identified action with a real backend-issued credential. 2. Change one signed field, then test an expired credential; both must fail. 3. Sign out and verify that another account cannot see the previous account's private report or chat. 4. Rotate the secret in a test project and confirm old credentials no longer work. --- # Appearance Pass an `AppNorthTheme` to the native views. The default primary color is SwiftUI's `.accentColor`; optional color pairs follow the screen's current light or dark appearance. ## Use your app colors {#theme} Leave optional colors unset to keep the native defaults. For explicit light/dark values, use `AppNorthColorPair(light:dark:)`. A single-color pair uses that color in both appearances. ```swift title="ThemedCommunity.swift" import SwiftUI import AppNorth struct ThemedCommunity: View { let client: AppNorthClient private var theme: AppNorthTheme { AppNorthTheme( primary: .accentColor, secondary: AppNorthColorPair( light: Color(white: 0.95), dark: Color(white: 0.12) ), tertiary: AppNorthColorPair(light: .white, dark: .black), text: AppNorthColorPair(.primary) ) } var body: some View { AppNorthView(client: client, theme: theme) } } ``` `primary` supplies tint and action accents. `secondary` supplies surfaces such as community rows and chat input backgrounds; `tertiary` supplies chat background. `text` overrides text where the views consume it. Status badges use the server's status color. Theme fields are not a global replacement for every system color. Pass the same theme to `AppNorthChatView` or `AppNorthIdeaDetailView` when presenting them separately. Use your existing SwiftUI `.preferredColorScheme` if your app has an appearance override; there is no separate SDK theme-mode setting. ## Choose controls {#controls} Use `AppNorthUIConfiguration` for `showsOpenClosedFilter`, `upvoteIcon`, `translateButton`, and `showsChatButton`. Icon choices are `.arrowUp` (default), `.thumbsUp`, `.chevronUp`, or `.systemName("...")` with a valid SF Symbol name. See [language](/docs/language#translation) for translation controls. The watermark is determined by the project's `widgetConfig()` response. It is not a local theme flag. The SDK has no remote font or remote color configuration API. ## Verify {#verify} 1. Open community, idea detail, and chat in light and dark appearances. 2. Check text, status badges, buttons, focus, and disabled states with your chosen colors. Aim for at least 4.5:1 text contrast. 3. Increase Dynamic Type and turn on VoiceOver; confirm labels remain usable and text does not disappear into overridden surfaces. 4. If your app changes appearance at runtime, switch it while an SDK screen is open. --- # Language and copy `AppNorthCopy()` follows the language in which the host app is displayed. Unsupported languages fall back to English. Supply a copy value to community, detail, or chat views to override their labels. ## Follow or override the language {#language} The bundled languages are Danish, German, English, Spanish, Finnish, French, Italian, Japanese, Korean, Norwegian Bokmål, Dutch, Polish, Brazilian Portuguese, Swedish, Turkish, Simplified Chinese, and Traditional Chinese. Use `AppNorthCopy(language:)` for an app-owned language picker. Supported identifiers include `en`, `es`, `de`, `pt-BR`, `zh-Hans`, and `zh-Hant`. Portuguese variants resolve to Brazilian Portuguese; `no` and `nn` resolve to `nb`. Chinese script or region chooses the Chinese table. ```swift title="LocalizedCommunity.swift" import SwiftUI import AppNorth struct LocalizedCommunity: View { let client: AppNorthClient let appLanguage: String private var copy: AppNorthCopy { var value = AppNorthCopy(language: appLanguage) value.feedback = "Ideas and updates" return value } var body: some View { AppNorthView(client: client, copy: copy) } } ``` The English label override above is intentional example copy: localize your override with your own app's resources. Each writable property replaces one SDK label. `AppNorthCopy.spanish` is a compatibility convenience for `AppNorthCopy(language: "es")`. The [API reference](/docs/swift-api#appnorthcopy) lists every label. ## Translate ideas on the device {#translation} On iOS 18 or later, the built-in detail screen uses Apple's Translation framework for an idea's title and description. `.automatic` offers the button when language detection considers the idea foreign; `.always` always offers it; `.hide` hides it. Detection is cached by idea ID. Translation text is not sent to AppNorth for translation. ```swift title="TranslationPreference.swift" import SwiftUI import AppNorth struct TranslationPreference: View { let client: AppNorthClient var body: some View { AppNorthView( client: client, uiConfiguration: AppNorthUIConfiguration(translateButton: .automatic) ) } } ``` Translation availability depends on the OS and Apple's supported language pairs and resources. On iOS 16 and 17 the translation control is absent, including with `.always`. The translation target follows the host app's language; overriding UI copy alone does not change that target. Community content, board names, and chat messages are not automatically rewritten by `AppNorthCopy`. ## Verify {#verify} 1. Set your app language to a supported language and inspect community, detail, and chat labels. 2. Pass an unsupported identifier and confirm English fallback. 3. On iOS 18 or later, open a foreign-language idea and switch between translation and original. 4. Check `.hide` and a device running an earlier supported OS; neither should show the translation control. --- # Offline writes and cache The client does not silently persist failed requests. Add `AppNorthOfflineQueue` for selected writes and `AppNorthReadCache` for boards and idea lists. You provide storage and decide its retention and protection. ## Provide storage {#storage} Both storage protocols expose `load() async throws -> Data?` and `save(_:) async throws`. The following actor is a minimal memory-only implementation. Use **separate instances** for the queue and cache; their serialized formats are different. ```swift title="MemorySDKStorage.swift" import Foundation import AppNorth actor MemorySDKStorage: AppNorthOfflineQueueStorage, AppNorthReadCacheStorage { private var data: Data? func load() async throws -> Data? { data } func save(_ data: Data?) async throws { self.data = data } } ``` For persistence across launches, implement these protocols with your app's protected storage. Queue contents can include report text, diagnostics, screenshots, and an identity scope. Do not store the signing secret or credential alongside them. ## Queue a write {#queue} Retain a queue for the account session. `submitIdea`, `submitComment`, and `submitFeedback` attempt the request immediately. They return `true` when submitted or `false` when a network failure was queued. ```swift title="QueuedComment.swift" import AppNorth func sendComment( queue: AppNorthOfflineQueue, client: AppNorthClient, ideaID: String, body: String, retryKey: String ) async throws -> Bool { try await queue.submitComment( using: client, handle: ideaID, body: body, idempotencyKey: retryKey ) } func retryPending(queue: AppNorthOfflineQueue, client: AppNorthClient) async throws -> Int { try await queue.flush(using: client) return try await queue.pendingCount() } ``` Only `AppNorthError.network` queues a write. Validation, authentication, and HTTP server errors propagate. The default queue limit is 50 entries; a full queue throws `AppNorthOfflineQueueError.full`. Votes and chat messages are not supported queue operations. Call `flush(using:)` after reconnecting or returning to the foreground. The queue does not monitor connectivity itself. A network failure stops the flush and retains the remaining entries; another error throws and leaves the failing entry pending. A completed flush call does not prove the queue is empty: inspect `pendingCount()`, which counts all stored scopes. ## Cache community reads {#cache} The cache defaults to a 300-second TTL. Pass it to `AppNorthView` to show fresh cached boards and ideas before the initial network load. It caches arrays, not pagination cursors, chat messages, or private report threads. ```swift title="CachedCommunity.swift" import SwiftUI import AppNorth struct CachedCommunity: View { let client: AppNorthClient let cache: AppNorthReadCache var body: some View { AppNorthView(client: client, cache: cache) } } ``` Construct the cache once with `AppNorthReadCache(storage: MemorySDKStorage())`, using the storage example above. Expired entries return `nil`; there is no stale-forever fallback. For your own reads, use `storeBoards`, `storeIdeas`, `boards(scope:)`, and `ideas(scope:query:)`. Include server, project, account, and all relevant filters in your cache keys. `clear(scope:)` removes that scope's boards and ideas. ## Separate accounts {#account-changes} Queue replay matches the API URL, App ID, and lowercased credential email (or `anonymous`). This prevents replaying an old account's queued write with a different account. It does not erase old entries automatically. On logout, stop submitting/flushing, discard the queue and view models, and erase account-owned storage if your retention policy requires it. Create new instances after changing identity. Clearing storage alone does not clear an already-loaded queue's in-memory entries. The built-in community model can retain its loaded arrays; recreate its screen on account changes as well. ## Verify {#verify} 1. Disable connectivity and submit one queued comment: expect `false` and one pending entry. 2. Reconnect, flush, and confirm one server comment and zero pending entries for this test. 3. Switch accounts before flushing and confirm the old entry is not sent as the new user. 4. Test cache reads before and after TTL expiry, and verify logout removes any data your app promises to forget. --- # Deep links Use `AppNorthDeepLinks` to create custom-scheme links to an idea, private report, or the current chat. The SDK parses a destination; your app owns URL registration, navigation, and authentication. ## Register and create links {#create} Register your app's URL scheme in Xcode's URL Types. Pass that same scheme to the helper. The scheme below is an example; replace it with your app's registered scheme. ```swift title="AppNorthURLs.swift" import Foundation import AppNorth func ideaURL(handle: String) throws -> URL { try AppNorthDeepLinks.idea(scheme: "myapp", handle: handle) } func reportURL(id: String) throws -> URL { try AppNorthDeepLinks.feedback(scheme: "myapp", id: id) } func chatURL() throws -> URL { try AppNorthDeepLinks.chat(scheme: "myapp") } ``` These produce `myapp://appnorth/ideas/`, `myapp://appnorth/feedback/`, and `myapp://appnorth/chat/current`. IDs and slugs are encoded as one path component. Empty values or invalid schemes throw `AppNorthError.invalidConfiguration`. ## Validate before routing {#route} `parse(_:)` checks the `appnorth` host and the two-part path. It does **not** verify that the URL uses your app's scheme. Check that in the host before accepting a link: ```swift title="AppNorthRouter.swift" import Foundation import AppNorth func routeAppNorthURL(_ url: URL, client: AppNorthClient) async throws -> AppNorthLink? { guard url.scheme?.lowercased() == "myapp", let link = AppNorthDeepLinks.parse(url) else { return nil } switch link.kind { case .idea: _ = try await client.idea(link.value) case .feedback: _ = try await client.feedbackThread(link.value) case .chat: guard try await client.chatStatus().available else { return nil } } return link } ``` Call this from your SwiftUI `onOpenURL` handler or UIKit URL callback, then navigate according to the returned link. For `.chat`, present `AppNorthChatView(client: client)` with that same client; `link.value` is `"current"`, not a conversation ID. Show a sign-in flow before resolving a private report or entering a chat that requires identified users. An ID in a link grants no access; report ownership is checked by the server. These custom-scheme links contain no project ID. Route them only through the intended project's client. The SDK does not register Universal Links or open screens automatically. Hosted roadmap URLs and identity handoff are separate server routes; there is no public Swift handoff helper. ## Open chat from a push notification {#push-chat} AppNorth chat notifications carry `appnorth.url: "appnorth://appnorth/chat/current"`, with `appnorth.type: "chat"` and the public App ID in `appnorth.appId`. Use the push facade to validate that envelope before routing. This payload URL uses the SDK's `appnorth` scheme; it is handled directly from the notification response, so you do not need to register that scheme in URL Types. ```swift title="ChatPush.swift" import Foundation import AppNorth @MainActor func configureChatPush( client: AppNorthClient, environment: AppNorthPushEnvironment, updateBadge: @escaping @MainActor (Int) -> Void, showChat: @escaping @MainActor () -> Void ) { AppNorth.configurePush( client: client, environment: environment, badgeHandler: updateBadge, routeHandler: { link in if link.kind == .chat { showChat() } } ) } @MainActor func handleChatNotificationTap(_ userInfo: [AnyHashable: Any]) -> Bool { AppNorth.handleNotificationTap(userInfo) } ``` Configure with the client used by your chat and the environment matching your APNs signing configuration. `showChat` is your app's presentation callback. From your `UNUserNotificationCenterDelegate` tap callback, forward `response.notification.request.content.userInfo` to `handleChatNotificationTap` on the main actor, and complete the delegate callback after handling. A false result leaves the notification for your other handlers. The helper rejects a different App ID or an invalid chat destination; it does not switch accounts or grant access. Your app still requests notification permission and registers with APNs. Forward the resulting raw device token with `try await AppNorth.registerPushToken(deviceToken)`, and register again after an identity change. Call `try await AppNorth.syncUnreadBadge()` on foreground and after reads in a custom chat UI; its badge value is 0 or 1, not a message count. Before logout, await `AppNorth.unregisterPushToken(deviceToken)`, then call `AppNorth.resetPush()` and clear identity. Handle failures so you can retry removal before clearing that identity. See the [push API reference](/docs/swift-api#appnorth) for signatures and lifecycle details. ## Verify {#verify} 1. Open an idea URL using your registered scheme and confirm the correct idea loads. 2. Test an encoded handle and reject a URL using another scheme or host. 3. Open a report as its author, signed out, and as another account. Only the author should retrieve it. 4. Check missing and deleted destinations with a useful error and a way back to your app. 5. Tap a chat notification for the configured App ID and confirm it opens your current chat. A foreign App ID or malformed chat URL must not invoke your route handler. --- # UIKit integration Host AppNorth's SwiftUI views in `UIHostingController`. Keep your existing app delegate, navigation, and dependency injection; configure and retain the client once per session. ## Present community {#community} The community view already supplies a navigation stack. Present its hosting controller directly rather than adding another navigation container. ```swift title="CommunityPresentation.swift" import UIKit import SwiftUI import AppNorth @MainActor func presentCommunity(from presenter: UIViewController, client: AppNorthClient) { let controller = UIHostingController(rootView: AppNorthView(client: client)) controller.modalPresentationStyle = .pageSheet presenter.present(controller, animated: true) } ``` The default sheet can be dismissed by swiping. If your app requires an explicit close button or a full-screen presentation, add a host-owned SwiftUI wrapper with a dismissal control. ## Present support with a close button {#chat} The chat view is conversation content, so you can put it inside your UIKit navigation controller: ```swift title="SupportViewController.swift" import UIKit import SwiftUI import AppNorth @MainActor final class SupportViewController: UIHostingController { init(client: AppNorthClient) { super.init(rootView: AppNorthChatView(client: client)) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("Use init(client:)") } override func viewDidLoad() { super.viewDidLoad() title = "Support" navigationItem.rightBarButtonItem = UIBarButtonItem( barButtonSystemItem: .done, target: self, action: #selector(closeSupport) ) } @objc private func closeSupport() { dismiss(animated: true) } } @MainActor func presentSupport(from presenter: UIViewController, client: AppNorthClient) { let navigation = UINavigationController( rootViewController: SupportViewController(client: client) ) presenter.present(navigation, animated: true) } ``` The built-in chat view manages visible/foreground polling. If you use `AppNorthChatModel` directly, you own its start/stop lifecycle. For an embedded hosting controller, use UIKit's normal `addChild`, view constraints, and `didMove(toParent:)` containment lifecycle. ## Keep identity and appearance aligned {#lifecycle} Install the signed credential before presenting identified screens. Dismiss them before logout or account changes, clear the credential and account state, and create new controllers for the next session. Pass `copy`, `theme`, and `paywalls` to the underlying SwiftUI view when needed. In a multiwindow app, supply the active window's presenter to the [RevenueCat helper](/docs/paywall-cards#helper) so a card opens above the correct chat. ## Verify {#verify} 1. Present and dismiss both screens from the existing UIKit app. 2. Open the keyboard, rotate the device, and test large text without clipping the composer. 3. Background and foreground support, then dismiss it; verify polling stops when hidden. 4. Switch accounts and confirm the newly presented controller has no previous account content. --- # Feedback inbox Open [Feedback](/dashboard/feedback) to work through private reports and support conversations for the selected app. Reports can be problems, suggestions or comments; chat messages appear as conversations. ## Triage a report {#triage} 1. Select the app and search the inbox. Filter by status, type, priority, label or read state. 2. Open a report to read its message and available context. Device details, screenshots and diagnostics appear only when they were supplied. 3. Set its priority, assign a teammate and add labels. Move it between to do, in progress and done as work advances. Read state and workflow status are separate: reading a report does not resolve it. The detail view keeps a timeline of changes. Editing and reply controls depend on your workspace permissions. ## Reply or leave a note {#replies} Use a reply for a message the user should receive. Use an internal note for team discussion; notes are excluded from the user's private thread and chat. Chat replies are available in the in-app conversation. Email delivery is a separate operation: it requires a recipient, notification access and an allowed email preference. Check the delivery state rather than assuming a saved reply was emailed. A non-chat report without a reporter email receives internal notes instead of outbound replies. The SDK refreshes chat while its chat model is polling. Saving a dashboard reply does not by itself guarantee an immediate device notification. See [Support chat](/docs/support-chat) for the native integration and [Paywall cards](/docs/paywall-cards) for offers in a conversation. ## Turn feedback into an idea {#promote} Use the promotion controls to create or link a community idea. Review the public title and description before publishing. Private contact information, screenshots and diagnostics do not belong in the public idea. Continue prioritization and moderation in [Ideas](/dashboard/ideas). See [Roadmap and changelog](/docs/roadmap-and-changelog) for the public surface. ## Export and access {#access} The inbox supports filtered CSV export. Treat exported reports as private customer data. Workspace membership and server-side permissions control access; a public App ID is not a dashboard credential. Demo conversations are examples. Use your own live app to verify SDK ingestion and delivery. --- # Roadmap and changelog Your app has a hosted roadmap at `/roadmap/` and a changelog at `/changelog/`. Use the **public App ID** from the app's installation page, not its internal dashboard project ID. Both links are available under `/dashboard/apps//install`. ## Manage the roadmap {#roadmap} Open [Ideas](/dashboard/ideas) to review proposals, edit their public content and change their status. Boards organize ideas; their statuses describe your workflow. The board manager is at `/dashboard/ideas/tableros`. Use the moderation queue at `/dashboard/ideas/moderacion` for content that needs review. Idea details include comments, moderation controls, demand and history. Duplicate ideas can be merged through the merge controls. Review the destination before merging. The hosted roadmap and [native community view](/docs/community-view) use the app's community data. Public views expose publishable content; private feedback remains in the inbox unless you deliberately create a public idea from it. ## Configure participation {#participation} Community participation policies control who may submit, vote and comment. An identified-only policy needs a valid credential from your backend. An email address typed by a user is not verified identity. App community settings live at `/dashboard/apps//widget`. They include hosted appearance, language and chat access. Configure native Swift appearance through [Appearance](/docs/appearance); do not assume hosted style settings replace native theme parameters. ## Open the hosted roadmap as a verified user {#handoff} A signed client can call `POST /api/v1/identity/handoff?app_id=` with the identity headers. An optional JSON body `{ "idea": "" }` targets one idea. The response includes a URL, `expiresAt` and `expiresIn`. Open that URL promptly: its fragment carries a single-use handoff code valid for 60 seconds. The hosted page exchanges it for an HttpOnly session cookie scoped to that app's roadmap. The cookie has a seven-day lifetime. Never put the signing secret or identity signature into a roadmap URL. See [Verified identity](/docs/verified-identity) for credential creation and account changes. ## Publish a changelog entry {#changelog} Open `/changelog//manage` while signed in as an owner or admin of the app's workspace. The app's installation page also links to this manager. 1. Create an entry with a title and body. 2. Save it as a draft while you prepare the announcement. 3. Publish it when it is ready, then open the public feed to check the result. Only published entries with a publication timestamp appear on the public changelog. Returning an entry to draft removes it from that public list. The manager uses `GET` and `POST /api/community/changelog` and `PATCH /api/community/changelog/`. Entries have a `draft` or `published` state; creation also selects the internal `projectId`. These management endpoints require a dashboard session and authorized same-origin mutations, not the SDK's public App ID alone. --- # Explore Open [Explore](/dashboard/explore/apps) to research iOS apps, App Store chart observations and collected creative material. This market catalog is separate from your own connected apps and their private RevenueCat data. ## Find apps {#apps} Search the apps list and use the available category, rating, release-date, country, chart, download and revenue filters. Sort the results and open an app profile for the available listing, screenshots, estimates and chart history. Filters and profile detail depend on workspace access. A profile can compare the selected market app with your active app when both have market records. No record means no comparison data; connecting your app does not guarantee market coverage. The profile header shows the stored App Store description (**See more** expands a long one), recorded store facts such as compatibility, size, content rating and version, and links to the app's website and known social accounts. Missing facts are left out rather than guessed. **Active ad · N d** appears only when an active ad has a recorded first-seen date; it counts days since AppNorth first detected that ad, not since the campaign started. Download and revenue sparklines appear only when monthly observations exist. **Export** downloads the profile as JSON or CSV, generated in your browser. It contains what the page shows for your plan, including each estimate's label, period and source; values your plan hides stay empty. ## Read estimates and dates {#estimates} Download and revenue figures are **estimates**, not verified App Store Connect sales or connected RevenueCat metrics. Revenue estimates are represented in USD. Read their source, period and observation date together; do not silently compare different periods or label estimates as actual revenue. Limited access may show broad ranges instead of individual estimated values. A range is not an exact measurement. Missing estimates stay unavailable rather than becoming zero, and an older observation is not a current measurement. Live profiles do not manufacture download-growth histories. Chart history uses comparable records for a particular country and chart; the current profile series selects the country's overall free-app chart. A collector update time is not an app release date. ## Charts and creative libraries {#libraries} - [Trending](/dashboard/explore/trending) shows collected chart rankings for the selected country. - [Rising](/dashboard/explore/rising) uses changes between comparable chart snapshots. Missing comparison data does not establish growth. - [Ads](/dashboard/explore/ads) and [Organic](/dashboard/explore/organic) show available collected media and source links. - [Onboardings](/dashboard/explore/onboardings) and [Paywalls](/dashboard/explore/paywalls) show available flow screens. Availability depends on imported records and collected media. An empty library does not prove an app has no ads, onboarding or paywall. Demo storyboards are examples, not playable recordings of real apps. ## Feed {#feed} Open the [Feed](/dashboard/explore/feed) to browse ads and organic creatives one at a time. Swipe or scroll vertically, use the previous and next buttons, or focus the feed and press Up/Down, Page Up/Page Down, or K/J. Auto-scroll advances every six seconds, pauses on hover, content focus or a hidden browser tab, and is disabled when reduced motion is enabled. The current feed displays collected poster images and example storyboards. The sound control is disabled because these items have no audio; there is no in-feed video playback. **See the ad** and **See the video** open the corresponding section of the app's market profile. Save an app with its watchlist button, subject to your workspace allowance and permissions. Filter by categories and store country, or reset the filters. Country refers to the app's indexed storefronts, not an ad's audience. Studio adds a minimum estimated monthly revenue filter and the Organic feed. Free and Pro can browse the Ads feed with revenue and download estimates locked. Content types without available creatives may be hidden. Live collections are paginated; use **Load more creatives** when offered at the end. Revenue in USD and monthly downloads are app-level estimates, not the creative's earnings or performance. Rating is the app's available rating; review count is currently unavailable in this view. For live ads, **Running for** is elapsed time since the recorded first-seen date of an ad marked active, not proof of uninterrupted delivery. Demo data is labelled **Example data** and uses fixed sample dates. ## Publishers {#publishers} [Publishers](/dashboard/explore/publishers), labelled **Estudios** in Spanish, groups indexed iOS apps into studios. It uses a developer ID when available, otherwise a normalized developer name. Apps without a known developer name are omitted. This is a catalog grouping, not a verified corporate ownership record or a complete list of a studio's apps. Search by studio name and filter by category, minimum app count, or launches in the last 90 days. With full market access, you can also filter by estimated monthly revenue, downloads and the number of apps with a positive revenue estimate. **New studios** means every indexed app launched in the last 90 days; it does not establish a company founding date. Studio provides the full list and sorting by revenue, downloads, app count or growth. The default is estimated revenue, highest first. Free and Pro show up to three matching studios, ordered by app count, with estimate filters locked. The row rank follows the selected order. Open a studio to see its indexed apps, available metrics, recent creatives and other studios sharing its main category. Limited profiles show up to three app rows and a plan wall for more detail. Monthly revenue and downloads are summed across indexed apps. A missing estimate makes the corresponding total unavailable. **Apps making money** counts positive revenue estimates, not verified paying customers or profitable businesses. Growth compares aggregate estimated downloads with the previous period where the required app data exists. The top app is selected by estimated revenue, then downloads; limited access removes those estimates before selecting it. The profile's rating is weighted by rating counts when all are available, otherwise by estimated downloads when possible. Missing data stays unavailable. Launch counts cover the past 365 days. Revenue history uses monthly worldwide observations from the same source for each app and includes only periods with values for every indexed app. It does not add country estimates together. Demo profiles use sample records. ## AppTracker {#apptracker} [AppTracker](/dashboard/explore/tracker) brings recorded activity for your watched apps into a timeline with 7-, 30- and 90-day periods. It uses the same watchlist and requires full market access plus a watchlist allowance. Read the [AppTracker guide](/docs/apptracker) for supported events, estimate thresholds, demo behavior and sidebar notifications. ## Watchlist and agent access {#watchlist} Save apps to your [Watchlist](/dashboard/explore/watchlist) to revisit them. Live watchlists are organization-scoped and respect the workspace allowance and management permissions. Demo watchlists use local browser storage. The current Free plan has no watchlist allowance; Pro allows five apps and Studio allows 50. A Pro watchlist does not unlock AppTracker. The workspace's effective entitlements determine access, including trials and installations with billing disabled. The [MCP market tools](/docs/mcp) expose research and watchlist operations to your AI client. Their access checks and API-credit rules are separate from simply viewing a dashboard list. Use the returned estimate labels and sources when presenting their output. ## Favorites {#favorites} Use the bookmark to save apps, individual ads, organic posts, paywall flows, onboarding flows and keyword searches with their store country to [Favorites](/dashboard/explore/favorites/apps/all). Choose **All** or a folder in the bookmark menu. Each item belongs to at most one folder; **All** always includes every favorite of that kind. Rename or delete a folder from its page. Deleting a folder keeps its items in All; removing a favorite only removes the bookmark. Unavailable catalog entries remain removable. Favorites are available on every plan. Owners, admins and members of the workspace can edit them. Each workspace can have up to **50 folders per kind** and **1,000 favorites per kind**. Folder names must be 1–60 trimmed characters and unique within the kind, ignoring case. Saving does not unlock paid catalog media or exact estimates. Demo favorites and folders are saved **in this browser**, separately for each demo workspace. They are not sent to a database or synchronized to another device. Browser storage must be available to keep changes. Favorites organize research. The separate radar control adds an app to **Seguimiento / Watchlist**, which supplies [AppTracker](/dashboard/explore/tracker) and still follows its plan allowance and manager permissions. Open **Manage watchlist** in AppTracker or search for Watchlist in the command menu to manage followed apps. --- # AppTracker Open [AppTracker](/dashboard/explore/tracker) to see collected activity for apps in your [Explore watchlist](/docs/explore#watchlist). Watched apps are the watchlist, not a separate subscription list. **Add apps** opens the app explorer; saving or removing an app changes that same list. ## Access and periods {#access} AppTracker requires full market access and a positive watchlist allowance. The current Studio plan includes both, with up to 50 watched apps. Free and Pro do not unlock AppTracker, even though Pro allows a five-app watchlist. Effective workspace entitlements also account for trials and installations with billing disabled. Owners and admins manage the live watchlist. Choose **7d**, **30d** or **90d**; the default is 30 days. Filter by app and event type. Events are grouped by date, newest first, and **View change** opens the relevant section of the market app profile. An empty period means no matching recorded events, not that nothing happened to the app. ## Recorded events {#events} - **Ranking:** entry into the top 10, 50 or 100, or a rise or fall of at least five positions. Live changes compare consecutive daily observations for the same country, chart and category. A newly observed entry needs evidence from the previous complete chart snapshot; a collection gap alone does not establish a new entry. - **Ads:** an ad with a recorded first-seen date in the period. This is detection, not a verified campaign launch time. - **Organic:** a collected social post with a recorded publication date in the period. The source must also mark it available. - **Onboardings / Paywalls:** a published flow with a capture date in the period. A new capture does not prove when the app changed its interface. - **Est. downloads:** an increase of at least 40% between observations for different periods from the same source and storefront. Both values must exist and the earlier estimate must be positive. This event does not report download decreases or actual App Store Connect downloads. Version, rating and review-count changes have no historical observations in AppTracker. The Version and Rating filters therefore have no events; current profile fields do not supply that missing history. Review alerts for your own connected apps in the sidebar are a separate source. Download events are **estimates**. Read their source, country, period and date together, using the same cautions as [Explore estimates](/docs/explore#estimates). Live activity comes from stored chart snapshots, metric observations, ads, social posts and captured flows; AppTracker does not continuously monitor the store during your visit. The timeline is bounded to 2,000 events and has collection limits per app. A notice appears when those limits may omit older activity. ## Demo watchlists {#demo} The demo reads the same browser-local watchlist as Explore. With no matching selected apps, it shows a labelled fallback of up to four example rising apps. Sample activity uses a fixed reference date rather than moving to today's date. Demo selections are also mirrored to a dashboard cookie so the sidebar can load matching example notifications. These selections do not create a live workspace watchlist. ## Sidebar notifications {#notifications} The sidebar's **Notifications** modal lists up to 20 recent AppTracker events from the last seven days when tracker access is available. Each links to AppTracker filtered to that app and seven-day period. Demo entries are labelled as examples. Use the bell on a watched app to mute its notifications; a muted app is marked **Muted** and its events stay in the timeline. Turn off individual event types in [Settings → Notifications](/dashboard/settings/notifications). Mutes, preferences and read state are stored in this browser, separately from the watchlist and underlying observations. See [Dashboard utilities](/docs/overview#dashboard) for the other notification types and sidebar modals. --- # Reviews Open `/dashboard/apps//reviews` to read an imported app's App Store reviews. First [connect App Store Connect](/docs/app-store-connect) and import the app. ## Read and synchronize {#sync} The inbox can filter by rating, territory, unanswered reviews and open or done status. Select a review to see its text and any developer response. Use synchronization to fetch changes from Apple; background synchronization also runs when the deployment's reviews job is configured. The list returns up to 200 matching reviews, newest first. It is not a complete historical export. A missing review may need synchronization or may fall outside that window. ## Draft and publish a reply {#reply} 1. Select a review and write a response, or request an AI draft. 2. Read and edit the draft. Check every claim about fixes, dates and your app. 3. Publish the response when it is ready. AI drafting sends the app name and review context to the configured AI provider. It returns text for review and does not publish to Apple. The prompt asks for the review's language, but you should verify the result. Drafting requires provider configuration and available workspace quota; demo mode uses a template. Publishing creates or replaces the developer response in Apple and records the action in AppNorth. The response may be pending publication; the returned Apple state is separate from the inbox's open/done status. You can delete a developer response or reopen a review from the inbox. Marking a review done alone does not send a response. ## Move a request to the roadmap {#ideas} A review can be promoted to an idea in an active board. Edit the proposed public title and description first. AppNorth retains the link to the idea so the review remains connected to the product discussion. ## If an action fails {#errors} Connection errors require fixing the App Store Connect key or waiting for the service to recover. `AI_NOT_CONFIGURED` and `DRAFT_UNAVAILABLE` concern drafting; you can still compose a reply manually when reply access is available. `PLAN_REQUIRED` means the relevant workspace allowance is unavailable. Owners and admins perform mutations. Demo responses stay in demo state and are not submitted to Apple. See [Errors](/docs/errors) for recovery guidance and [MCP](/docs/mcp) for agent-driven review workflows. --- # Keywords Open `/dashboard/apps//keywords` for an imported iOS app. Track a term in a specific App Store country; the same term in another country is a separate observation and counts toward the tracking allowance. To research any term before choosing an app, use [Keyword Explorer](/docs/keyword-explorer). It shows public search results, estimated difficulty and related terms by country. Its daily live-search quota is separate from this page's workspace tracking allowance; Explorer searches do not automatically track terms. ## Add and compare terms {#tracking} Add terms with a storefront, then use the list or country grid to compare observations. The grid groups localized terms so you can see how a concept performs across markets. Expand a group to inspect the actual term used in each country. The localization sheet lets you enter and review localized terms manually or copy a prompt for your connected AI client. Saving the group adds tracking terms; it does not publish App Store metadata. Review translations before saving them. Duplicate normalized terms in the same app and storefront are not added again. Removing a tracked term stops tracking it for that app. Workspace keyword limits are enforced across the organization. ## Read the ranking correctly {#rankings} - A numeric rank is the app's position in the collected search results for that term and country. - No observation means no measurement has been collected yet. - An observed term with no rank means the app was absent from the returned result set. It does not prove the app has no downloads or no search visibility elsewhere. - The seven-day change is available only when both comparable dated ranks exist. Positive values mean positions gained. Rank history uses stored observations. Collection runs through the configured keywords job; adding a term does not promise an immediate rank. The server defaults to the iTunes Search API and can be configured to use App Store search pages. Results are specific to that source, country and observation time. ## Candidates and difficulty {#candidates} Candidate terms come from the app's public listing and apps found in stored searches. They retain source evidence and exclude terms already tracked. Candidate suggestions are not search-volume measurements. Difficulty is a computed assessment of stored search results, not a guarantee that a term will rank. Use the competitor view to inspect who appears above your app, then decide which terms fit your product. Send selected candidates to the [Metadata](/docs/metadata) workflow, where changes are reviewed and published separately. The [MCP reference](/docs/mcp) covers keyword tools and localization prompts. --- # Keyword Explorer Open [Keyword Explorer](/dashboard/aso/keywords), enter a term of 1–100 characters and choose a supported App Store country. You can research a term without first choosing an app. This requires ASO access, currently included in Pro and Studio; Free shows a plan gate. The workspace's effective entitlements determine access. Use Explorer for a snapshot of competing apps and related terms. Use [per-app Keywords](/docs/keywords) to maintain an app's tracked terms, compare countries and review stored rank history. An Explorer search alone does not add a tracked keyword or change App Store metadata. ## Results and sources {#results} The ranking is the order returned by the configured source for that term and country. The default is the iTunes Search API, whose order may differ from the App Store. An installation can instead use App Store search pages. Each analysis identifies its source and observation time and labels cached, live or example data. Results show available app names, ratings, rating counts, release dates and pricing or in-app-purchase information. Some details come from the indexed market catalog; prices are only shown when their stored country matches the search. Missing fields appear as **—**. Your imported apps are marked **Your app**. Available links open a market profile or the app's public App Store listing. The result count is the number of apps returned, not search volume or a count of everyone competing for the term. Top-rating and review summaries use the first ten returned apps, or the smaller available sample. A missing required value makes that summary unavailable. ## Cache and daily quota {#quota} Live Explorer searches have a limit of **30 per workspace per UTC day**, shared by its members. The counter resets at **00:00 UTC**. This is separate from the tracked-keyword allowance. Public search observations are stored on the server in a shared cache keyed by normalized term, country, UTC date and source. Explorer and the scheduled keyword collector use the same observations, shared across workspaces. Reopening an observation already cached for that day uses no Explorer quota, even after the daily live-search allowance is exhausted. It does not force a new store search. A cache miss reserves quota before contacting the source, so an attempted search that fails can still consume a search. The page shows remaining searches and distinguishes provider failures, rate limiting and unreadable search pages. Recent successful searches are a separate list of up to six term-and-country pairs stored in this browser for the workspace. ## Difficulty and related terms {#difficulty} Difficulty is a **competition estimate from 0 to 100**, with a low, medium or high label. It is calculated from stored ranking-app details and available earlier observations; incomplete evidence can make it provisional or unavailable. It is not a guarantee that your app will rank. Apple does not provide public search-volume data here, and Explorer does not present popularity or volume scores. Related searches are up to 12 candidate terms extracted from returned app titles. They are suggestions for further research, not measured demand. Open a related term to analyze it in the selected country, under the same cache and quota rules. ## Track a term in an app {#tracking} Owners and admins can choose an unlocked app imported from App Store Connect and use **Track in an app**. Saving requires an active subscription, ASO access and available tracked-keyword capacity. The current allowances are 200 tracked keywords for Pro and 500 for Studio across the workspace. The selected term and country are saved for that app; another country is a separate tracked keyword. An already tracked normalized term in the same app and country is not added again. Continue in [per-app Keywords](/docs/keywords#tracking) for tracking, localization and rank history. Adding a term does not promise an immediate fresh rank; observations come from the configured collector and shared cache. ## Demo mode {#demo} The demo labels its results and quota as examples. It uses sample observations without making live searches or consuming quota. **Track in an app** is read-only in the demo and does not save a keyword. --- # Metadata Open `/dashboard/apps//metadata` for an app imported from [App Store Connect](/docs/app-store-connect). The editor reads the current metadata from Apple and lets you switch between available localizations. ## Edit a localization {#edit} The editable fields are title, subtitle, keywords, promotional text, description and what's new. Select a locale, make changes and review the field counters and validation messages. The keywords field is measured in UTF-8 bytes. Its 100-byte budget is not necessarily 100 visible characters. The editor also flags issues such as empty terms, duplicates and repeated words across metadata fields. Keyword suggestions are generated from the supplied candidates and current metadata. Applying a suggestion changes the editor's draft. It does not publish anything. See [Keywords](/docs/keywords) to collect and evaluate candidates first. ## Publish reviewed changes {#publish} Publishing sends only fields that differ from what you loaded. AppNorth validates the resulting localization, rereads Apple's values and checks the expected values for fields being changed. Authorized dashboard managers can publish when the app has an editable version and workspace access permits it. This updates metadata in App Store Connect. It does not submit a build for review or release an app version. ## Resolve a conflict {#conflicts} | Result | Meaning | Next step | | --------------------- | ------------------------------------------------- | ------------------------------------------------------------- | | `READ_ONLY` | No editable version is available | Prepare an editable version in App Store Connect, then reload | | `LOCALE_NOT_FOUND` | The selected localization is unavailable | Reload and select an existing localization | | `STALE` | A changed field no longer matches what you loaded | Reload Apple's latest values and reapply the intended edit | | `INVALID_METADATA` | The resulting content fails validation | Fix the highlighted fields | | `PARTIALLY_PUBLISHED` | Some fields were saved but a later write failed | Reload and inspect which values reached Apple before retrying | A publish operation can span app-info and version-localization writes. Do not treat a partial failure as an automatic rollback. The server records publish outcomes for audit. Demo metadata is stored in memory and does not reach Apple. For agent workflows, use the preview and expected-value contract in [MCP](/docs/mcp). --- # Price localization Open `/dashboard/apps//prices` for an imported App Store Connect app. This workspace previews localized product prices and queues the changes you approve. ## Build a preview {#preview} 1. Select products from the Apple catalog. 2. Choose purchasing-power localization or currency conversion (`match_fx`). 3. Review the base price, strategy preset, rounding and price floors. The current base territory is `USA`. 4. Inspect the proposed Apple price point for every selected territory before scheduling. Advanced controls include tier multipliers, country floors, territory moves and pinned price points. A territory lock requests an explicit manual price. Proposals use the available pricing dataset and FX snapshot; source dates matter. A preview is a calculation, not a prediction of sales or revenue. CSV import is validated against the current catalog. `STALE_CSV` means the file no longer matches the values it was based on; generate a fresh preview instead of forcing an old file through. ## Queue changes {#queue} Choose the changes, start date and whether to preserve current subscriber prices, then submit the job. Review any consent indication, including an unknown indication, before approval. The preview is not confirmation that Apple will accept a subscription change. A job submission records work in AppNorth. The configured prices worker later processes it; submission alone does not mean the prices are live. The worker rereads Apple's prices before each write, checks for conflicting changes and verifies that the selected price point remains available. The base territory is processed last. ## Follow the result {#results} | Item state | Meaning | | ------------------ | ------------------------------------------------------------------ | | `pending` | Waiting to be processed | | `running` / `sent` | Processing or a write has been attempted | | `succeeded` | The target was already present or Apple accepted the write | | `failed` | A known failure needs attention | | `uncertain` | A request may have reached Apple; its outcome needs reconciliation | | `cancelled` | The pending work was stopped | A successful scheduled change may still have a future effective date. An uncertain write is checked against Apple before it can be sent again. Cancel stops remaining work; it does not undo prices already sent. Retry handles eligible failed or uncertain items. Rollback creates compensating work where a previous price is available and can itself fail. Restoring a preview lets you review the recorded proposal again. For automated scheduling, [MCP](/docs/mcp) requires a preview and explicit confirmation. Keep live credentials out of your iOS app. --- # App Store Connect Connect your Apple team to AppNorth to import apps and use reviews, metadata and price localization. The connection belongs to the workspace organization and can serve its imported apps. ## Connect and import {#connect} 1. Open [Apps](/dashboard/apps) and the App Store Connect connection panel, or use the connection step during onboarding. 2. Provide the Issuer ID, Key ID and matching private `.p8` key. The key must have access to the apps and operations you intend to use. 3. Save the connection. AppNorth validates the key format and attempts to list apps from Apple before storing it. 4. Select the apps to import. Existing imports are recognized by their App Store Connect app ID. Import creates AppNorth projects linked to the Apple app and bundle ID. If the selection exceeds the workspace's app allowance, the import is rejected. Connecting Apple does not install the native SDK; follow [Get started](/docs/get-started) for feedback and chat. ## Credentials and permissions {#credentials} Only workspace owners and admins can manage the connection. The server checks the dashboard session and same-origin mutation before accepting a change. The private key is sealed with AES-256-GCM and organization/connection-specific authenticated context. Dashboard responses expose the key identifiers, connection state and verification information, not the private key. Keep the `.p8` on the server; never place it in Swift code, app resources or a public repository. A successful app-list check establishes access to that request. Later review, metadata or pricing operations can still fail if the key lacks the necessary Apple permissions or the app is not editable. ## Replace or disconnect {#disconnect} Use the panel to replace the connection's credentials or disconnect it. Imported projects remain when the connection is removed, but no longer have a connected store credential. Check their store-dependent workflows after reconnecting. ## Connection errors {#errors} | Code | Action | | --------------------------- | ----------------------------------------------------------------------------------------------- | | `INVALID_CREDENTIALS` | Check the IDs and the matching P-256 private key | | `CREDENTIALS_REJECTED` | Check whether Apple revoked the key or denied its access | | `CREDENTIALS_UNREADABLE` | Ask the deployment operator to restore encryption-key access, or reconnect with the private key | | `ENCRYPTION_NOT_CONFIGURED` | The deployment operator must configure credential encryption | | `STORE_UNAVAILABLE` | Retry after the provider or connection recovers | | `UNKNOWN_APPS` | Reload the accessible app list and select again | Demo mode does not save credentials or contact Apple. --- # RevenueCat Connect RevenueCat from your app's overview at `/dashboard/apps/`. AppNorth stores one RevenueCat connection per AppNorth app and displays snapshots of the selected RevenueCat project's overview metrics. ## Connect a project {#connect} 1. As a workspace owner or admin, select **Connect** in the RevenueCat panel. 2. Supply a RevenueCat secret v2 API key (`sk_…`) with access to the project and `charts_metrics:overview:read` permission. 3. If the key can see multiple projects, choose the intended project and submit again. 4. AppNorth checks project access and the overview metrics request before saving the connection and its first snapshot. The public SDK key used in an iOS app is not the secret key this integration expects. AppNorth seals the secret on the server and does not return it in connection responses. ## Understand the metrics {#metrics} The overview maps RevenueCat metrics to MRR, revenue over 28 days, active subscriptions, active trials, new customers over 28 days and active users over 28 days. Currency comes from the provider response. These are **project-level metrics**. If one RevenueCat project contains several apps, its overview is not filtered down to the individual AppNorth app. Choose the project accordingly and avoid adding the same project's figures together as if they were independent app revenue. Unavailable metrics remain unknown. A missing snapshot is not zero revenue. Check the last synchronization time before using the figures. AppNorth stores one snapshot per app per UTC day and updates that day's row when the revenue job runs. The overview reads up to 90 days of stored history. Connecting starts collection; it does not backfill 90 days of history. Background refresh depends on the deployment's revenue job. ## Change or remove the connection {#disconnect} Use **Change key** to verify and replace credentials, or **Disconnect** to stop using the connection. Previously collected snapshots remain as history. If you switch RevenueCat projects, remember that retained history may describe the former project. `PROJECT_REQUIRED` asks you to select a project. `MISSING_PERMISSION` means the overview permission is missing; `CREDENTIALS_REJECTED` means the provider rejected the key. See [Errors](/docs/errors) for encryption and service failures. This integration reads metrics. [Paywall cards](/docs/paywall-cards) separately configure how your iOS app handles offers in support chat. --- # Telegram Telegram is AppNorth’s primary chat-ops integration; Discord remains available separately. Telegram is available on Pro and Studio, including self-hosted instances with billing disabled. Owners and admins manage connections per app. ## Connect your app 1. Open **Settings → Apps → your app → Integrations → Telegram**. 2. Open **@BotFather** in Telegram and send `/newbot`. Create a dedicated bot for this app and paste its token into AppNorth. Select **Save bot and register webhook**. One bot can connect to only one AppNorth app; registering this webhook replaces any existing webhook for that bot. 3. Create a private team group, enable **Topics**, add the bot, and make it an administrator with permission to manage topics. Keep customer conversations in a group restricted to your support team. 4. Send a message in the group. AppNorth detects groups from `my_chat_member` and message updates. Select **Refresh groups**, choose the detected group, and save it. Detection alone never starts forwarding conversations. 5. Start a new conversation from your app. AppNorth creates a topic named after the reporter and app, then posts the initial message with available device, OS and app-version details and a dashboard link. Missing details are labelled unavailable. The setup instructions can be copied in English or Spanish. Existing conversations are not backfilled. New user messages, ordinary team replies and status changes are forwarded. Internal notes, images and interactive chat cards are not forwarded as media or cards. ## Reply and resolve Reply with text inside the topic. Group administrators and the group creator can reply as **Team**. Bot messages, anonymous `sender_chat` messages, service messages and unsupported commands are ignored. Only open conversations accept replies. Send `/resolve` (or `/resolve@your_bot_username`) in the topic to resolve the conversation in AppNorth. In a normal group without Topics, reply directly to an AppNorth conversation message; replies to imported team messages also work. To resolve there, reply `/resolve` to one of those messages. The message-to-conversation mapping prevents unrelated group messages entering the chat. An AppNorth owner/admin can optionally map a Telegram user to an existing organization member with a same-origin authenticated `PUT /api/integrations/telegram/connection?projectId=APP_ID`: ```json { "action": "member", "telegramUserId": "TELEGRAM_NUMERIC_ID", "userId": "APPNORTH_MEMBER_USER_ID" } ``` Mapped users must still belong to the Telegram group and the AppNorth organization. Their replies are attributed to that member; unmapped users must be Telegram administrators. There is no email or username-based automatic identity linking. ## Hosting - Apply migration `0042_telegram` after the preceding migrations. - Configure `APPNORTH_CREDENTIALS_KEY` using the same versioned AES-256-GCM keyring as App Store Connect credentials. Bot tokens and webhook secrets are sealed with organization/app-specific associated data. - Set `NEXT_PUBLIC_APP_URL` to the public HTTPS origin. Telegram sends updates to `/api/integrations/telegram/webhook/[integrationId]`. Every update must pass the `X-Telegram-Bot-Api-Secret-Token` check. - Run the existing cron scheduler with `CRON_SECRET` and `APPNORTH_APP_URL`. Its `telegram` job calls `GET /api/internal/telegram` every minute to drain outbound work. It does not poll Telegram for inbound messages. You can run one pass with `node scripts/cron.mjs telegram`. The transactional outbox captures chat/report creation, replies and status changes in the same tenant transaction. Inbound update IDs and reply keys suppress repeated deliveries. Outbound operations persist topic creation and each message chunk separately; rate limits and transient errors retain pending work with backoff. Telegram’s Bot API has no client idempotency key for topic creation or sends: if Telegram accepts an operation but its response or the subsequent database commit is lost, a retry can produce a duplicate topic/message. AppNorth does not promise exactly-once external delivery. ## Disconnect and troubleshoot Disconnect before changing the bot or group. Disconnecting revokes the local integration and removes pending work and mappings; existing Telegram messages remain. AppNorth attempts to delete the webhook, but local revocation succeeds even if Telegram is unavailable. Reconnecting starts with new conversations. If no group appears, save the bot again to retry webhook registration, verify the public HTTPS URL is reachable, add the bot to the group and send a new message. If sending fails, check administrator and topic-management permissions. Delivery and inbound replies pause when the workspace loses paid access. Provider errors shown in the UI are generic and never include credentials. --- # Discord Discord connects one text channel to one AppNorth app. It is available on Pro and Studio; self-hosted instances with billing disabled also have access. Free workspaces can see the connection and disconnect it, but cannot install, send test messages or sync. New chats and reports create a channel message and a thread. The message includes the app name, the reporter's email when provided (otherwise an anonymous-user label), the first message, available device/OS/app-version details, and a dashboard link. Missing device information is labelled unavailable. The chat transport currently supplies the platform and bundle ID, so chat messages may have fewer device details than reports. Subsequent user messages and ordinary team replies are mirrored into the thread. Internal notes and paywall cards are not sent. Status changes appear as thread messages, including resolution. Reports created before connecting are not backfilled. ## Connect a channel 1. Open **Settings → Apps → your app → Integrations**. 2. Select **Connect Discord** as an AppNorth owner or admin. 3. Authorize the bot in a server you own or can manage. 4. Choose a text channel and save it. Only channels where the bot has all required permissions appear. 5. Send a test message. Use a channel restricted to your support team: reports include private customer messages and may include an email address. The selected server and channel are stored per app. To move to another channel or server, disconnect first. Existing Discord messages are retained, but pending deliveries and thread mappings are removed on disconnect; reconnecting starts with new conversations. The onboarding Integrations step shows the current connection and opens the same settings page in a new tab. It refreshes when you return to the tab. ## Reply from Discord Post a text message in the report's thread. While the conversation is open, AppNorth imports it as a team reply, and the SDK receives it through its existing chat polling endpoint. The Discord account that authorized installation is mapped to that AppNorth organization member. Re-authorizing an existing installation maps the current installer's Discord account as well. A mapping only applies while the user is still a member of that organization. Other Discord users must have **View Channel** and **Manage Messages** on the parent channel; their imported replies are attributed to **Team**. Bot messages, webhook messages and system events are ignored, preventing reply loops. Attachments, edits and deletions are not synchronized. Resolved conversations are no longer polled. Reopen the conversation in AppNorth before sending further replies from Discord. Disconnecting stops all synchronization. ## Configure the platform bot Create an application and bot in the [Discord Developer Portal](https://discord.com/developers/applications). Set these server-side variables: ```dotenv DISCORD_APPLICATION_ID=your_application_id DISCORD_PUBLIC_KEY=your_application_public_key DISCORD_BOT_TOKEN=your_bot_token DISCORD_CLIENT_SECRET=your_oauth_client_secret ``` The platform bot token and OAuth client secret stay in the server environment. AppNorth does not store per-organization bot tokens or OAuth access tokens. The OAuth access token is used transiently to verify the installer and their server permissions; only installation IDs, member mappings and one-use hashed OAuth state are persisted. No credentials are sent to the browser. Set the OAuth redirect URL to: ```text https://YOUR_APPNORTH_HOST/api/integrations/discord/callback ``` It must match `NEXT_PUBLIC_APP_URL`. Enable the **Message Content** privileged intent in the bot settings, obtaining Discord approval when required. The worker verifies the application’s intent flags before reading messages, so missing access does not silently advance the reply cursor. The intent governs message content over REST as well as Gateway connections; see [Discord's intent documentation](https://docs.discord.com/developers/events/gateway#message-content-intent). The installation requests these bot permissions: View Channel, Send Messages, Embed Links, Read Message History, Create Public Threads and Send Messages in Threads. Channel permission overrides must also allow them. Forum, media and announcement channels are not supported. The optional Interactions endpoint is: ```text https://YOUR_APPNORTH_HOST/api/integrations/discord/interactions ``` It verifies Ed25519 signatures against the raw request body and rejects timestamps older than five minutes. Only Discord's PING is supported; normal messages do not arrive through Interactions. PING has no side effects, so replay does not create replies. ## Run synchronization Apply migration `0037_discord` through the normal migration process. The tables, indexes, RLS policies and capture triggers are defined in that migration; the Discord repository uses parameterized SQL rather than generated Prisma models. For the self-hosted Compose deployment, enable the dedicated HTTP poller: ```bash docker compose -f compose.selfhost.yaml --profile discord up -d ``` The poller calls `POST /api/integrations/discord/sync` with `Authorization: Bearer `, waits for completion, and starts another pass after 30 seconds. An external scheduler can call the same endpoint. The web process needs the bot variables; the poller only needs `CRON_SECRET`. The implementation uses HTTP polling rather than Gateway: it avoids a persistent WebSocket, reconnect/resume logic and another bot runtime. Thread cursors and incoming reply IDs survive process restarts. Each pass has a time budget and processes at most ten outgoing events and ten incoming pages per app; busy installations take longer than one interval. Organizations and threads are visited fairly across passes. At larger scale, a Gateway consumer could reduce polling requests without changing the outbox or reply deduplication. ## Delivery and recovery Database triggers capture new reports, messages and status changes in the same tenant transaction as the original write. Each pending delivery remains in `discord_outbox` until it completes. Transactions use `withTenant`; project advisory locks prevent concurrent workers and disconnects from racing. Incoming message IDs become reply idempotency keys. Reply insertion and cursor advancement commit together. Outgoing sends use a stable nonce plus a persisted Discord message marker to recover after a process dies between a successful Discord request and a database commit. Thread creation reuses its starter message ID. Failures retry with exponential backoff and jitter, up to one hour between ordinary retries. HTTP 429 responses honor Discord's `retry_after` and persist a bot-wide pause. Permission errors remain visible in settings and are retried after permissions are fixed. The worker rechecks the paid plan before delivery, including inbound replies. History reconciliation scans up to 1,000 messages. If it cannot establish whether an event was already delivered within that bound, it records `RECONCILE_LIMIT` and retains the event rather than sending a possible duplicate. Completed outbox rows are removed after 30 days; pending events have no automatic discard deadline. Discord-side deletion of a sent message also removes its reconciliation marker, so delivery cannot be guaranteed exactly once after manual deletion. ## Test coverage The fake provider tests cover message → thread → reply → chat, long messages, status mirroring, unauthorized senders, bot-loop prevention and pagination. HTTP tests cover signatures, provider rate limits, OAuth/session boundaries, roles and plan gating. The opt-in `worker.live.test.ts` additionally exercises real Postgres capture triggers, rollback, RLS, concurrent replay, SDK chat reads, durable backoff and one-use OAuth state against a dedicated test database. A real bot installation still needs a smoke test with your Discord application and channel. --- # Servidor MCP Conecta tu agente a AppNorth para consultar tus apps iOS, preparar cambios en App Store y analizar tu mercado. Usa el mismo token y endpoint para las herramientas de tu app y las de Explore. Las escrituras se previsualizan por defecto; tú autorizas su ejecución. ## Conectar tu agente 1. Abre [ajustes de tu cuenta](/dashboard/settings) y crea un token API. Guarda el valor cuando aparezca: después no se vuelve a mostrar. 2. Configura una conexión MCP de tipo Streamable HTTP con la URL `https://www.appnorth.app/api/v1/mcp`. En una instalación propia, sustituye el dominio por el de tu hub. 3. Envía `Authorization: Bearer ` en cada petición. Los tokens del hub empiezan por `an_`; nunca los pegues en prompts públicos, commits ni código del SDK iOS. 4. Pide al agente que consulte `get_guardrails` y `tools/list` antes de trabajar. `prompts/list` devuelve las guías disponibles. La organización viene del token, nunca de un argumento de la herramienta. El autor debe seguir siendo miembro de esa organización. Un token con `read` permite consultas y previsualizaciones; ejecutar cambios requiere `write` y que su autor siga siendo owner o admin. Revoca el token desde ajustes cuando deje de hacer falta. Los antiguos tokens `al_mcp_` de AppLibrary no funcionan en este endpoint. Crea un token del hub; esta integración no convierte credenciales antiguas automáticamente. ## Permisos y planes de mercado | Acceso | Free | Pro | Studio | | ---------------------------------------------- | ------------------------------- | ------------------------------- | -------------------------------- | | Catálogo básico y ranking de Trending | Sí | Sí | Sí, ficha completa | | Fichas de app | 10 consultas al día, con rangos | 10 consultas al día, con rangos | Fichas completas, con créditos | | Watchlist | No | Hasta 5 apps | Según el límite vigente del plan | | Filtros avanzados, comparaciones y Rising | No | No | Sí, con créditos | | Pantallas, anuncios, posts, vídeos y artículos | No | No | Sí, con créditos | Free y Pro reciben rangos de estimación, sin bibliotecas ni cifras exactas. La cuota diaria se comparte entre todos los tokens de la organización y se renueva a medianoche UTC. Cada consulta de ficha admitida cuenta, incluso si el slug no existe; repetir una consulta no evita el límite. Las herramientas de tu propia app mantienen sus permisos y límites: que un token pueda usar MCP no desbloquea ASO, respuestas a reviews ni cambios de precios de otro plan. ### Créditos mensuales Cada lectura de mercado admitida en Studio consume **un crédito por llamada**, incluida una comparación de varias apps. La watchlist, sus previsualizaciones y los accesos limitados de Free/Pro no consumen créditos API. Descubrir herramientas o prompts tampoco consume créditos. Los créditos pertenecen a la organización, se comparten entre tokens y se renuevan el primer día de cada mes a las 00:00 UTC. Se reservan antes de consultar datos: una llamada admitida que después devuelve contenido no encontrado o un fallo de lectura también consume su crédito. Una llamada rechazada por falta de plan, cuota o validación no consume créditos. La asignación procede del catálogo de billing. **La asignación actual de Studio es cero, pendiente de aprobación del producto**: mientras siga así, las lecturas de mercado Studio devuelven `API_CREDITS_EXHAUSTED`. No se supone una cuota ni se ofrece acceso ilimitado por tener cero. Las instalaciones con billing desactivado usan el acceso sin límite configurado para self-hosting. Los resultados incluyen `credits` junto a los datos, en `structuredContent` y en el bloque de texto JSON: ```json { "period": "YYYY-MM", "limit": 100, "used": 12, "remaining": 88, "cost": 1, "unlimited": false, "resets_at": "YYYY-MM-01T00:00:00.000Z" } ``` Los números anteriores solo ilustran el formato; no son una cuota comercial ni datos de uso. `limit` y `remaining` son `null` cuando `unlimited` es `true`. Un error anterior a la lectura del contador puede no incluir `credits`. Las consultas de fichas limitadas incluyen también `daily` con el límite, lo que queda y la próxima renovación. ## Herramientas de tu app | Herramienta | Qué hace | | -------------------------- | -------------------------------------------------------------------------- | | `get_guardrails` | Reglas de permisos, previsualización, confirmación y datos. | | `get_aso_playbook` | Guía de ASO y mercado; no ejecuta cambios. | | `list_apps` | Apps accesibles de tu organización e identificadores de App Store Connect. | | `get_metadata` | Metadata de la versión editable, por locale. | | `get_metadata_suggestions` | Propuesta determinista para el campo keywords. | | `update_metadata` | Previsualiza o publica cambios de metadata. | | `list_keywords` | Keywords seguidas, posiciones e histórico disponible. | | `get_keyword_candidates` | Candidatos de keywords. | | `add_keywords` | Previsualiza o añade keywords al seguimiento. | | `remove_keywords` | Previsualiza o elimina una keyword seguida. | | `get_keyword_grid` | Cuadrícula por keyword y país. | | `get_competitors_ahead` | Competidores observados por delante en tus keywords. | | `get_localization_plan` | Locales, países y presupuesto de keywords. | | `add_localized_keywords` | Previsualiza o crea un grupo localizado de keywords. | | `get_ratings` | Valoraciones e histórico disponibles. | | `list_reviews` | Reviews de tu app, con filtros. | | `reply_review` | Previsualiza o envía una respuesta a una review. | | `suggest_localized_prices` | Propone price points disponibles en Apple. | | `schedule_price_changes` | Previsualiza o programa cambios de precios. | | `list_feedback` | Feedback recibido desde tu app. | ## Herramientas de mercado Los slugs y los IDs salen de los resultados del servidor: no los deduzcas del nombre. `adamId` es el ID numérico de la app en App Store, distinto del `app_id` de un proyecto de tu organización. Las listas aceptan `limit` (por defecto 25, máximo 50) y `offset` (por defecto 0, máximo 100000), y devuelven `items` y `nextOffset`. Si `nextOffset` es `null`, no hay otra página. `market_get_app` pagina cada biblioteca anidada con esos mismos parámetros. Las comparaciones, una imagen y un artículo no usan paginación. | Herramienta | Argumentos principales y resultado | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `market_search_apps` | `query`, `category`; filtros Studio: `paywallType`, `tag`, `minRevenueUsd`, `hasVideo`, `hasAds`, `flowType`. `sort`: `revenue`, `downloads`, `rating` o `recent`. Devuelve apps publicadas. | | `market_get_app` | `slug`. Ficha básica o completa; Studio recibe métricas, charts y bibliotecas paginadas. | | `market_search_screens` | `query`, `flowType`, `tag`. Pantallas con sus IDs y la app/flujo al que pertenecen. | | `market_compare_apps` | `slugs`: de 2 a 5. Compara en el orden solicitado, con estimaciones, paywall y patrones. | | `market_list_flow_screens` | `flowId`. Pantallas de un flujo publicado en su orden original. | | `market_get_screen_image` | `screenId`. Metadatos y URL firmada de la imagen, válida durante 600 segundos. No devuelve bytes de imagen ni aplica un watermark nuevo. | | `market_list_app_ads` | `slug`, `platform`, `activeOnly`. Anuncios más recientes primero. | | `market_list_social_posts` | `slug`, `platform`, `source`, `sort` (`views` o `recent`). Solo posts ya disponibles. | | `market_get_video_chapters` | `slug`. Duración, tipo y capítulos de un vídeo listo; no descarga el vídeo. | | `market_list_articles` | `app` (slug), `kind`, `tag`. Análisis publicados y apps vinculadas. | | `market_get_article` | `slug`. Análisis en Markdown con referencias de evidencia conservadas. | | `market_trending` | `storefront` (por defecto `us`), `chart`, `genre`. Ranking del último snapshot completo, con su fecha real. | | `market_rising` | Mismos filtros, más `kind`: `riser`, `faller` o `new`. Cambios entre snapshots completos; no inventa una comparación si falta histórico. | | `market_watchlist` | `action`: `list`, `add` o `remove`; para cambios, `adamId` y opcionalmente `note`. Lee o modifica tu watchlist. | `flowType` admite `onboarding`, `full-app`, `paywall`, `cancellation`, `activation` y `retention`. `chart` admite `topfreeapplications`, `toppaidapplications` y `topgrossingapplications`. La fecha del snapshot identifica la observación: un resultado no promete que los datos sean de hoy. Ingresos y descargas de mercado son **estimaciones**, no revenue conectado de RevenueCat. Conserva fuente, periodo y fecha al citarlas. Un dato ausente permanece `null`; no significa cero. Un ranking alto no prueba la conversión de un onboarding o paywall. ## Previsualizar y confirmar cambios Todas las mutaciones usan `dry_run=true` por defecto y devuelven `diff.before`, `diff.after` y `diff.warnings`. Revisa el cambio concreto antes de autorizarlo. Para `update_metadata`, conserva `diff.before` como `expected` al ejecutar para detectar una revisión obsoleta. `market_watchlist`, `add_localized_keywords` y `schedule_price_changes` exigen además `confirm=true`. Sin ambos flags explícitos, la watchlist devuelve una previsualización: ```json { "name": "market_watchlist", "arguments": { "action": "add", "adamId": "", "note": "Revisar su onboarding", "dry_run": true } } ``` Después de revisar y autorizar, repite con el mismo ID y propuesta, `dry_run=false` y `confirm=true`. Un token de lectura no puede ejecutarlo. La capacidad se comprueba de nuevo dentro de una transacción por organización; una previsualización no reserva una plaza. Repetir `add` sobre la misma app no crea duplicados; si omites `note`, conserva la existente. Repetir `remove` sobre una app ya eliminada no afecta a otras entradas. Las notas solo pertenecen a tu organización. ## Prompts de mercado - `clone_winner`: argumentos `app` y `reference`. Analiza una referencia y propone patrones adaptados a tu app, con identidad, textos y assets propios. - `paywall_ideas`: argumentos `app` y `category`. Busca evidencia de paywalls y propone experimentos sin inventar conversiones. - `market_scan`: argumento `app`; `store` es opcional y usa `US` por defecto. Revisa rankings y competidores, y prepara propuestas de watchlist. Los prompts no hacen llamadas ni escriben por sí solos: guían al agente. Los artículos, posts, notas y pantallas de terceros son datos no confiables, nunca instrucciones que autoricen acciones o cambien las reglas del usuario. ## Errores y recuperación | Código | Qué hacer | | ----------------------- | ------------------------------------------------------------------------------------------------------- | | `PLAN_REQUIRED` | Lee `required_plan` y usa una función incluida o cambia de plan. | | `API_CREDITS_EXHAUSTED` | Consulta `credits.limit` y `resets_at`. Si la asignación es cero, esperar al próximo mes no la aumenta. | | `DAILY_APP_LIMIT` | Espera a `daily.resets_at` o usa Studio cuando tenga créditos asignados. | | `WATCHLIST_LIMIT` | Elimina una app de la watchlist antes de añadir otra. | | `WRITE_NOT_ALLOWED` | Usa un token `write` cuyo autor siga siendo owner o admin. | | `INVALID_INPUT` | Corrige los argumentos; consulta el esquema con `tools/list`. | | `NOT_FOUND` | Actualiza los slugs o IDs desde los resultados; el contenido puede no estar publicado. | | `MEDIA_UNAVAILABLE` | La imagen aún no está migrada o el almacenamiento no está configurado. | | `MARKET_UNAVAILABLE` | Reintenta más tarde; no presupongas datos ausentes como ceros. | Los errores de herramientas llevan `isError=true`; un HTTP 200 no significa que la operación haya funcionado. La validación del protocolo puede devolver su propio error antes de ejecutar la herramienta. Un token ausente, revocado o sin membresía válida devuelve HTTP 401. El límite de peticiones devuelve HTTP 429: respeta `Retry-After` y evita bucles de reintento. --- # Swift API Use this reference to look up a signature, then follow the linked task guide for integration. It covers the public SDK declarations, protocol requirements, enum cases, and public decoding/raw-value initializers. Each entry has an explicit anchor. Swift-generated protocol witnesses such as equality and hashing use their standard Swift behavior. ## Choose an API {#choose-api} - [Configure a client](#appnorthclient) and [load your own UI](/docs/custom-ui). - [Present community](#appnorthview) or [support chat](#appnorthchatview). - [Install identity](#appnorthidentitycredential) and [submit private feedback](#appnorthcreatefeedback). - [Style views](#appnorththeme), [override labels](#appnorthcopy), or [configure paywalls](#appnorthpaywalls). - [Queue writes](#appnorthofflinequeue), [cache reads](#appnorthreadcache), or [parse links](#appnorthdeeplinks). - [Configure push](#appnorth) and [route notification taps](/docs/deep-links#push-chat). Response structs without an explicit public value initializer are read from the client or a Decoder; do not rely on an internal memberwise initializer. Observable models and the paywall controller run on the main actor. Published properties documented with `{ get }` are externally read-only; their `$property` projections expose observation, not permission to mutate. ```swift title="ReadCommunity.swift" import AppNorth func firstCommunityPage(client: AppNorthClient) async throws -> AppNorthPage { let boards = try await client.boards() return try await client.ideas(board: boards.first?.slug, sort: .top) } ``` ## AppNorthTokenProviding {#appnorthtokenproviding} Supplies a stable anonymous token. A custom provider must be safe for concurrent access and return a token accepted by the server. `protocol AppNorthTokenProviding : Sendable` ### `token()` {#appnorthtokenproviding-token} `func token() -> String` ## AppNorthAnonymousToken {#appnorthanonymoustoken} Thread-safe anonymous voter token backed by UserDefaults. The default key is `appnorth.voter`; a valid legacy token is migrated. Removing stored data or using another installation can create a different token. This is not verified identity. `final class AppNorthAnonymousToken` ### `init(defaults:key:)` {#appnorthanonymoustoken-init-defaults-key} `init(defaults: UserDefaults = .standard, key: String = "appnorth.voter")` ### `token()` {#appnorthanonymoustoken-token} `func token() -> String` ## AppNorthViewModel {#appnorthviewmodel} Main-actor observable community state. Optional read caching applies to initial load; recreate the model after account changes to discard previously loaded arrays. `@MainActor final class AppNorthViewModel` ### `boards` {#appnorthviewmodel-boards} `@Published @MainActor var boards: [AppNorthBoard] { get }` ### `ideas` {#appnorthviewmodel-ideas} `@Published @MainActor var ideas: [AppNorthIdea] { get }` ### `isLoading` {#appnorthviewmodel-isloading} `@Published @MainActor var isLoading: Bool { get }` ### `error` {#appnorthviewmodel-error} `@Published @MainActor var error: AppNorthError? { get }` ### `showWatermark` {#appnorthviewmodel-showwatermark} `@Published @MainActor var showWatermark: Bool { get }` ### `selectedBoard` {#appnorthviewmodel-selectedboard} `@Published @MainActor var selectedBoard: String? { get set }` ### `sort` {#appnorthviewmodel-sort} `@Published @MainActor var sort: AppNorthIdeaSort { get set }` ### `client` {#appnorthviewmodel-client} `@MainActor let client: AppNorthClient` ### `init(client:cache:)` {#appnorthviewmodel-init-client-cache} `@MainActor init(client: AppNorthClient, cache: AppNorthReadCache? = nil)` ### `load()` {#appnorthviewmodel-load} `@MainActor func load() async` Loads fresh cached values if available, then boards and the first idea page; loads branding independently. ### `reloadIdeas()` {#appnorthviewmodel-reloadideas} `@MainActor func reloadIdeas() async` Reloads the first idea page for the selected board and sort and stores successful results in the optional cache. ## AppNorthView {#appnorthview} Complete native community screen with its own navigation stack, board/sort controls, idea details, creation, and optional floating chat entry. Loads the first idea page. `@MainActor struct AppNorthView` ### `init(client:copy:theme:uiConfiguration:cache:paywalls:)` {#appnorthview-init-client-copy-theme-uiconfiguration-cache-paywalls} `@MainActor init(client: AppNorthClient, copy: AppNorthCopy = .init(), theme: AppNorthTheme = .init(), uiConfiguration: AppNorthUIConfiguration = .init(), cache: AppNorthReadCache? = nil, paywalls: AppNorthPaywalls? = nil)` ### `body` {#appnorthview-body} `@MainActor var body: some View { get }` The content and behavior of the view. When you implement a custom view, you must implement a computed `body` property to provide the content for your view. Return a view that's composed of built-in views that SwiftUI provides, plus other composite views that you've already defined: struct MyView: View { var body: some View { Text("Hello, World!") } } For more information about composing views and a view hierarchy, see . ## AppNorthScreenshotCapturing {#appnorthscreenshotcapturing} Host-supplied capture adapter. Return a supported base64 image data URL; the SDK provides no automatic screen capture. `protocol AppNorthScreenshotCapturing : Sendable` ### `capture()` {#appnorthscreenshotcapturing-capture} `func capture() async throws -> String` ## AppNorthScreenshotRedacting {#appnorthscreenshotredacting} Host-supplied redaction adapter. Return a changed, valid image data URL; unchanged output is rejected by the capture helper. `protocol AppNorthScreenshotRedacting : Sendable` ### `redact(_:)` {#appnorthscreenshotredacting-redact} `func redact(_ screenshot: String) async throws -> String` ## AppNorthChatEntry {#appnorthchatentry} A displayed chat row, including local delivery state. Obtain entries from `AppNorthChatModel`; there is no public memberwise initializer. `struct AppNorthChatEntry` ### `id` {#appnorthchatentry-id} `var id: String` The stable identity of the entity associated with this instance. ### `sender` {#appnorthchatentry-sender} `let sender: AppNorthChatMessage.Sender` ### `body` {#appnorthchatentry-body} `let body: String` SwiftUI view content. Present the view itself rather than invoking this property directly. ### `actionCard` {#appnorthchatentry-actioncard} `var actionCard: AppNorthActionCard?` ### `openedAt` {#appnorthchatentry-openedat} `var openedAt: String?` ### `tappedAt` {#appnorthchatentry-tappedat} `var tappedAt: String?` First review-card tap recorded by the server; this does not confirm a submitted review. ### `state` {#appnorthchatentry-state} `var state: AppNorthChatEntry.State` ### `images` {#appnorthchatentry-images} `var images: [AppNorthChatImage]` Photos shown with the optimistic or delivered bubble. Failed sends retain their photos for retry. ## AppNorthChatEntry.State {#appnorthchatentry-state-type} Local delivery state: `sending`, `sent`, or `failed`. Failed entries can be retried with their original key. `enum State` ### `sent` {#appnorthchatentry-state-sent} `case sent` ### `sending` {#appnorthchatentry-state-sending} `case sending` ### `failed` {#appnorthchatentry-state-failed} `case failed` ## AppNorthChatModel {#appnorthchatmodel} Main-actor conversation model with optimistic entries and retry handling. A custom owner must stop polling while hidden or inactive and discard the model on account changes. See [support chat](/docs/support-chat). `@MainActor final class AppNorthChatModel` ### `maximumLength` {#appnorthchatmodel-maximumlength} `@MainActor static let maximumLength: Int` ### `entries` {#appnorthchatmodel-entries} `@Published @MainActor var entries: [AppNorthChatEntry] { get }` ### `isAvailable` {#appnorthchatmodel-isavailable} `@Published @MainActor var isAvailable: Bool { get }` ### `hasLoaded` {#appnorthchatmodel-hasloaded} `@Published @MainActor var hasLoaded: Bool { get }` ### `lastLoadFailed` {#appnorthchatmodel-lastloadfailed} `@Published @MainActor var lastLoadFailed: Bool { get }` ### `init(client:paywalls:)` {#appnorthchatmodel-init-client-paywalls} `@MainActor init(client: AppNorthClient, paywalls: AppNorthPaywalls? = nil)` ### `startPolling()` {#appnorthchatmodel-startpolling} `@MainActor func startPolling()` Starts a refresh loop at five-second intervals; cancels any earlier polling task. ### `stopPolling()` {#appnorthchatmodel-stoppolling} `@MainActor func stopPolling()` Cancels polling. Call when your custom chat screen hides or becomes inactive. ### `refresh()` {#appnorthchatmodel-refresh} `@MainActor func refresh() async` Fetches and merges server messages, updating availability and failure state. Discards results for a changed identity scope. ### `send(_:images:)` {#appnorthchatmodel-send} `@discardableResult @MainActor func send(_ text: String, images: [AppNorthChatImageUpload] = []) -> Bool` Trims text and caps it at 2,000 Swift characters. Returns true when a local entry was queued, not when delivery succeeds. ### `retry(_:)` {#appnorthchatmodel-retry} `@MainActor func retry(_ entry: AppNorthChatEntry)` Retries a failed entry with its original idempotency key. ## AppNorthChatView {#appnorthchatview} Native conversation content. Owns a model and visible/foreground polling; your app supplies presentation and dismissal. Accepts copy, theme, and an optional paywall controller. `@MainActor struct AppNorthChatView` ### `init(client:copy:theme:paywalls:)` {#appnorthchatview-init-client-copy-theme-paywalls} `@MainActor init(client: AppNorthClient, copy: AppNorthCopy = .init(), theme: AppNorthTheme = .init(), paywalls: AppNorthPaywalls? = nil)` ### `body` {#appnorthchatview-body} `@MainActor var body: some View { get }` The content and behavior of the view. When you implement a custom view, you must implement a computed `body` property to provide the content for your view. Return a view that's composed of built-in views that SwiftUI provides, plus other composite views that you've already defined: struct MyView: View { var body: some View { Text("Hello, World!") } } For more information about composing views and a view hierarchy, see . ## AppNorthHTTPTransport {#appnorthhttptransport} Implement this protocol for a custom transport or deterministic tests. `URLSession` already conforms. Return both response bytes and the HTTP response; transport errors become `AppNorthError.network`. `protocol AppNorthHTTPTransport : Sendable` ### `data(for:)` {#appnorthhttptransport-data-for} `func data(for request: URLRequest) async throws -> (Data, URLResponse)` ## AppNorthClient {#appnorthclient} A Sendable HTTP client. Copies share the same in-memory identity store. Both initializers support an anonymous token provider, an optional credential, and an injectable clock. See [custom UI](/docs/custom-ui). `struct AppNorthClient` ### `configuration` {#appnorthclient-configuration} `let configuration: AppNorthConfiguration` ### `init(configuration:session:tokenProvider:identityCredential:clock:)` {#appnorthclient-init-configuration-session-tokenprovider-identitycredential-clock} `init(configuration: AppNorthConfiguration, session: URLSession = .shared, tokenProvider: any AppNorthTokenProviding = AppNorthAnonymousToken(), identityCredential: AppNorthIdentityCredential? = nil, clock: @escaping @Sendable () -> Date = { Date() })` ### `init(configuration:transport:tokenProvider:identityCredential:clock:)` {#appnorthclient-init-configuration-transport-tokenprovider-identitycredential-clock} `init(configuration: AppNorthConfiguration, transport: any AppNorthHTTPTransport, tokenProvider: any AppNorthTokenProviding = AppNorthAnonymousToken(), identityCredential: AppNorthIdentityCredential? = nil, clock: @escaping @Sendable () -> Date = { Date() })` ### `setIdentityCredential(_:)` {#appnorthclient-setidentitycredential} `func setIdentityCredential(_ credential: AppNorthIdentityCredential)` Replaces the in-memory credential for subsequent requests. Does not recreate screens or clear host storage. ### `clearIdentityCredential()` {#appnorthclient-clearidentitycredential} `func clearIdentityCredential()` Removes the credential only. Dismiss and discard account-specific views and storage separately. ### `hasValidIdentity(at:)` {#appnorthclient-hasvalididentity-at} `func hasValidIdentity(at date: Date = Date()) -> Bool` Checks that a credential exists and has not expired at the supplied date; does not verify its signature. ### `canParticipate(_:at:)` {#appnorthclient-canparticipate-at} `func canParticipate(_ policy: AppNorthParticipation, at date: Date = Date()) -> Bool` Evaluates the policy against local credential expiry. The server makes the authorization decision. ### `boards()` {#appnorthclient-boards} `func boards() async throws -> [AppNorthBoard]` Returns the project’s boards, statuses, and policies. ### `widgetConfig()` {#appnorthclient-widgetconfig} `func widgetConfig() async throws -> AppNorthWidgetConfig` Loads public presentation configuration independently of community data. ### `ideas(board:status:query:sort:cursor:limit:)` {#appnorthclient-ideas-board-status-query-sort-cursor-limit} `func ideas(board: String? = nil, status: String? = nil, query: String? = nil, sort: AppNorthIdeaSort = .top, cursor: String? = nil, limit: Int = 20) async throws -> AppNorthPage` Returns one page. Limit defaults to 20 and is clamped to 1–50. Preserve filters when reusing the opaque cursor. ### `idea(_:)` {#appnorthclient-idea} `func idea(_ handle: String) async throws -> AppNorthIdea` Loads one idea by ID or slug. ### `comments(for:cursor:limit:)` {#appnorthclient-comments-for-cursor-limit} `func comments(for handle: String, cursor: String? = nil, limit: Int = 20) async throws -> AppNorthPage` Returns a paginated comment list for an idea ID or slug; limit is clamped to 1–50. ### `createIdea(board:title:description:authorName:idempotencyKey:)` {#appnorthclient-createidea-board-title-description-authorname-idempotencykey} `func createIdea(board: String, title: String, description: String = "", authorName: String? = nil, idempotencyKey: String = UUID().uuidString) async throws -> AppNorthCreation` Creates an idea. Retain the same idempotency key and payload on retries; inspect the moderation flag. ### `setVote(on:voted:)` {#appnorthclient-setvote-on-voted} `func setVote(on handle: String, voted: Bool) async throws -> AppNorthVote` Sets the desired vote state, returning the resulting state, count, and whether it changed. ### `createComment(on:body:authorName:idempotencyKey:)` {#appnorthclient-createcomment-on-body-authorname-idempotencykey} `func createComment(on handle: String, body: String, authorName: String? = nil, idempotencyKey: String = UUID().uuidString) async throws -> AppNorthCreation` Creates a comment. Preserve key and payload on retries; inspect the moderation flag. ### `createFeedback(_:idempotencyKey:)` {#appnorthclient-createfeedback-idempotencykey} `func createFeedback(_ input: AppNorthCreateFeedback, idempotencyKey: String = UUID().uuidString) async throws -> AppNorthFeedbackReceipt` Submits private feedback and returns its receipt. Reuse the same key only for the same user intention. ### `feedbackThread(_:)` {#appnorthclient-feedbackthread} `func feedbackThread(_ id: String) async throws -> AppNorthFeedbackThread` Requires a signed credential and server-verified ownership. Returns only the report and public staff replies. ### `chatStatus()` {#appnorthclient-chatstatus} `func chatStatus() async throws -> AppNorthChatStatus` Reads availability and unread state without marking messages read. ### `chatMessages(after:)` {#appnorthclient-chatmessages-after} `func chatMessages(after: String? = nil) async throws -> AppNorthChatThread` Reads messages and marks team replies read. Pass a server message ID as `after` for newer messages. ### `sendChatMessage(_:imageTokens:idempotencyKey:)` {#appnorthclient-sendchatmessage-idempotencykey} `func sendChatMessage(_ body: String, imageTokens: [String] = [], idempotencyKey: String = UUID().uuidString) async throws -> AppNorthChatMessage` Sends chat text and up to three uploaded image tokens. Text may be empty when images are attached. Reuse the key and tokens when retrying the same message. ### `registerPushToken(_:environment:)` {#appnorthclient-registerpushtoken-environment} `func registerPushToken(_ token: Data, environment: AppNorthPushEnvironment) async throws` Registers the raw APNs device token for the current chat identity. Call after APNs supplies a token and again after an identity change. Pass the APNs environment matching your app's signing configuration. Empty tokens or tokens larger than 256 bytes throw `AppNorthError.invalidConfiguration` before a request is made. ### `unregisterPushToken(_:environment:)` {#appnorthclient-unregisterpushtoken-environment} `func unregisterPushToken(_ token: Data, environment: AppNorthPushEnvironment) async throws` Removes the token registration using the current identity and the same environment used to register it. Await this before clearing the signed identity on logout. Token validation matches registration; request failures propagate so your app can retry. ### `reportPaywalls(_:idempotencyKey:)` {#appnorthclient-reportpaywalls-idempotencykey} `func reportPaywalls(_ snapshot: AppNorthPaywallSnapshot, idempotencyKey: String = UUID().uuidString) async throws` Replaces the reporter’s device snapshot. Reuse the same key for a retry. ### `markActionCardOpened(_:)` {#appnorthclient-markactioncardopened} `func markActionCardOpened(_ messageId: String) async throws` Reports first display for review cards and first tap for offer cards with a deterministic `card-open-` key. Does not report a purchase or a submitted review. ### `markActionCardTapped(_:)` {#appnorthclient-markactioncardtapped} `func markActionCardTapped(_ messageId: String) async throws` Reports a review-card tap using the deterministic `card-tap-` key. It does not invoke StoreKit or confirm that its prompt appeared. ### `uploadChatImage(_:contentType:)` {#appnorthclient-uploadchatimage-contenttype} `func uploadChatImage(_ data: Data, contentType: String = "image/jpeg") async throws -> AppNorthChatImageUpload` Uploads up to 5 MB of JPEG, PNG or WebP through a signed storage PUT and completes server-side pixel validation and metadata removal. Custom UIs prepare their own resized pixels; the built-in iOS picker handles this automatically. Pass the returned `imageToken` in `sendChatMessage(_:imageTokens:idempotencyKey:)`. At most three image tokens may accompany a message; its text may be empty. Keep the same tokens and idempotency key on retry. See [Photos in chat](/docs/support-chat#photos-in-chat). ## AppNorthPushEnvironment {#appnorthpushenvironment} `enum AppNorthPushEnvironment: String, Codable, Sendable` Choose the APNs environment matching your app's signing configuration; the SDK does not infer it. ### `sandbox` {#appnorthpushenvironment-sandbox} `case sandbox` ### `production` {#appnorthpushenvironment-production} `case production` ## AppNorth {#appnorth} `@MainActor enum AppNorth` Optional push facade for one configured chat client at a time. Your app owns notification permission prompts, APNs registration, its notification-center delegate, and navigation. The SDK does not swizzle callbacks. See the [push chat example](/docs/deep-links#push-chat). ### `configurePush(client:environment:badgeHandler:routeHandler:)` {#appnorth-configurepush-client-environment-badgehandler-routehandler} ```swift @MainActor static func configurePush( client: AppNorthClient, environment: AppNorthPushEnvironment, badgeHandler: @escaping @MainActor (Int) -> Void, routeHandler: @escaping @MainActor (AppNorthLink) -> Void ) ``` Stores the same client used by chat, the APNs environment, and your callbacks. `badgeHandler` receives 0 or 1 to indicate unread chat replies, not a message count; combine it with any other badge sources in your app. `routeHandler` receives accepted chat destinations and must present your chat UI. ### `registerPushToken(_:)` {#appnorth-registerpushtoken} `@MainActor static func registerPushToken(_ token: Data) async throws` Forwards the raw token to the configured client's `registerPushToken(_:environment:)`. Throws `AppNorthError.invalidConfiguration` if `configurePush` has not been called. Call again after changing the chat identity. ### `unregisterPushToken(_:)` {#appnorth-unregisterpushtoken} `@MainActor static func unregisterPushToken(_ token: Data) async throws` Forwards removal to the configured client and environment. Await before logout or disabling push; failures propagate for retry. Throws `AppNorthError.invalidConfiguration` without a configured client. ### `handleNotificationTap(_:)` {#appnorth-handlenotificationtap} `@discardableResult @MainActor static func handleNotificationTap(_ userInfo: [AnyHashable: Any]) -> Bool` Pass the notification response's `userInfo`. Returns true and invokes `routeHandler` only when the `appnorth` payload has `type: "chat"`, an `appId` equal to the configured client's `configuration.appId`, and a parseable chat URL using the `appnorth` scheme. Unconfigured, foreign-app, and malformed payloads return false without routing. It does not open a screen or authenticate the user. ### `syncUnreadBadge()` {#appnorth-syncunreadbadge} `@discardableResult @MainActor static func syncUnreadBadge() async throws -> Int` Fetches `chatStatus()` and returns 1 only when chat is available and has unread replies; otherwise returns 0. Calls `badgeHandler` only if the identity scope and push configuration are still current. Request failures preserve the existing badge and propagate. Throws `AppNorthError.invalidConfiguration` without a configured client. Call on foreground and after reading with a custom UI; built-in chat also calls it after a successful read. ### `resetPush()` {#appnorth-resetpush} `@MainActor static func resetPush()` Calls the current badge handler with 0 and releases the client and callbacks. This only resets local state: await `unregisterPushToken(_:)` before resetting and clearing identity to remove the server registration. ## AppNorthColorPair {#appnorthcolorpair} A light/dark SwiftUI color pair, or one color shared by both appearances. `struct AppNorthColorPair` ### `light` {#appnorthcolorpair-light} `var light: Color` ### `dark` {#appnorthcolorpair-dark} `var dark: Color` ### `init(light:dark:)` {#appnorthcolorpair-init-light-dark} `init(light: Color, dark: Color)` ### `init(_:)` {#appnorthcolorpair-init} `init(_ color: Color)` Uses one color in both appearances. ### `color(in:)` {#appnorthcolorpair-color-in} `func color(in appearance: ColorScheme) -> Color` Selects the dark color for dark appearance and the light color otherwise. ## AppNorthConfiguration {#appnorthconfiguration} Validated server URL, public App ID, and allowed write origin. Use HTTPS in production and pass the server base URL without `/api/v1`. See [installation](/docs/install#configure). `struct AppNorthConfiguration` ### `apiURL` {#appnorthconfiguration-apiurl} `let apiURL: URL` ### `appId` {#appnorthconfiguration-appid} `let appId: String` ### `origin` {#appnorthconfiguration-origin} `let origin: URL` ### `init(apiURL:appId:origin:)` {#appnorthconfiguration-init-apiurl-appid-origin} `init(apiURL: URL, appId: String, origin: URL) throws` ## AppNorthError {#appnortherror} Typed client failures. Server errors preserve the HTTP status, machine code, and optional field errors; see [error handling](/docs/custom-ui#errors). `enum AppNorthError` ### `invalidConfiguration(_:)` {#appnortherror-invalidconfiguration} `case invalidConfiguration(String)` ### `invalidIdentityCredential(_:)` {#appnortherror-invalididentitycredential} `case invalidIdentityCredential(String)` ### `invalidResponse` {#appnortherror-invalidresponse} `case invalidResponse` ### `network(_:)` {#appnortherror-network} `case network(String)` ### `server(status:code:fields:)` {#appnortherror-server-status-code-fields} `case server(status: Int, code: String, fields: [String : [String]]?)` ### `decoding(_:)` {#appnortherror-decoding} `case decoding(String)` ### `errorDescription` {#appnortherror-errordescription} `var errorDescription: String? { get }` A localized message describing what error occurred. ## AppNorthCopy {#appnorthcopy} Mutable SDK labels. The default follows the host app language; unsupported languages fall back to English. Each string property below overrides the corresponding label. See [language](/docs/language). `struct AppNorthCopy` ### `feedback` {#appnorthcopy-feedback} `var feedback: String` Override this label in the copy value passed to your screen. ### `board` {#appnorthcopy-board} `var board: String` Override this label in the copy value passed to your screen. ### `allBoards` {#appnorthcopy-allboards} `var allBoards: String` Override this label in the copy value passed to your screen. ### `noDescription` {#appnorthcopy-nodescription} `var noDescription: String` Override this label in the copy value passed to your screen. ### `comments` {#appnorthcopy-comments} `var comments: String` Override this label in the copy value passed to your screen. ### `writeComment` {#appnorthcopy-writecomment} `var writeComment: String` Override this label in the copy value passed to your screen. ### `send` {#appnorthcopy-send} `var send: String` Override this label in the copy value passed to your screen. ### `signInToComment` {#appnorthcopy-signintocomment} `var signInToComment: String` Override this label in the copy value passed to your screen. ### `commentsDisabled` {#appnorthcopy-commentsdisabled} `var commentsDisabled: String` Override this label in the copy value passed to your screen. ### `newIdea` {#appnorthcopy-newidea} `var newIdea: String` Override this label in the copy value passed to your screen. ### `title` {#appnorthcopy-title} `var title: String` Override this label in the copy value passed to your screen. ### `description` {#appnorthcopy-description} `var description: String` Override this label in the copy value passed to your screen. ### `yourName` {#appnorthcopy-yourname} `var yourName: String` Override this label in the copy value passed to your screen. ### `cancel` {#appnorthcopy-cancel} `var cancel: String` Override this label in the copy value passed to your screen. ### `signInToSubmit` {#appnorthcopy-signintosubmit} `var signInToSubmit: String` Override this label in the copy value passed to your screen. ### `submissionDisabled` {#appnorthcopy-submissiondisabled} `var submissionDisabled: String` Override this label in the copy value passed to your screen. ### `anonymous` {#appnorthcopy-anonymous} `var anonymous: String` Override this label in the copy value passed to your screen. ### `team` {#appnorthcopy-team} `var team: String` Override this label in the copy value passed to your screen. ### `vote` {#appnorthcopy-vote} `var vote: String` Override this label in the copy value passed to your screen. ### `removeVote` {#appnorthcopy-removevote} `var removeVote: String` Override this label in the copy value passed to your screen. ### `error` {#appnorthcopy-error} `var error: String` Override this label in the copy value passed to your screen. ### `open` {#appnorthcopy-open} `var open: String` Override this label in the copy value passed to your screen. ### `closed` {#appnorthcopy-closed} `var closed: String` Override this label in the copy value passed to your screen. ### `pending` {#appnorthcopy-pending} `var pending: String` Override this label in the copy value passed to your screen. ### `poweredBy` {#appnorthcopy-poweredby} `var poweredBy: String` Override this label in the copy value passed to your screen. ### `somethingWentWrong` {#appnorthcopy-somethingwentwrong} `var somethingWentWrong: String` Override this label in the copy value passed to your screen. ### `seeTranslation` {#appnorthcopy-seetranslation} `var seeTranslation: String` Override this label in the copy value passed to your screen. ### `seeOriginal` {#appnorthcopy-seeoriginal} `var seeOriginal: String` Override this label in the copy value passed to your screen. ### `noIdeas` {#appnorthcopy-noideas} `var noIdeas: String` Override this label in the copy value passed to your screen. ### `filter` {#appnorthcopy-filter} `var filter: String` Override this label in the copy value passed to your screen. ### `activateToSwitchFilter` {#appnorthcopy-activatetoswitchfilter} `var activateToSwitchFilter: String` Override this label in the copy value passed to your screen. ### `charactersRemaining` {#appnorthcopy-charactersremaining} `var charactersRemaining: String` Override this label in the copy value passed to your screen. ### `chat` {#appnorthcopy-chat} `var chat: String` Override this label in the copy value passed to your screen. ### `writeMessage` {#appnorthcopy-writemessage} `var writeMessage: String` Override this label in the copy value passed to your screen. ### `chatEmpty` {#appnorthcopy-chatempty} `var chatEmpty: String` Override this label in the copy value passed to your screen. ### `sendFailed` {#appnorthcopy-sendfailed} `var sendFailed: String` Override this label in the copy value passed to your screen. ### `done` {#appnorthcopy-done} `var done: String` Override this label in the copy value passed to your screen. ### `init()` {#appnorthcopy-init} `init()` Texts in the language the host app is currently displayed in. ### `init(language:)` {#appnorthcopy-init-language} `init(language: String)` Texts in a specific language, e.g. `"de"` or `"pt-BR"`. Unsupported languages fall back to English. ### `spanish` {#appnorthcopy-spanish} `static var spanish: AppNorthCopy { get }` ### Photo control copy `var attachPhotos: String` — photo picker button. `var removePhoto: String` — remove a selected photo. `var attachedPhoto: String` — accessible image description. `var photoLimits: String` — count and size guidance. All four properties have bundled translations and can be overridden. ## AppNorthCreateIdeaView {#appnorthcreateideaview} Native creation form with board selection and policy-aware submission. The completion callback lets your host reload after creation. `@MainActor struct AppNorthCreateIdeaView` ### `init(client:boards:copy:theme:onCreated:)` {#appnorthcreateideaview-init-client-boards-copy-theme-oncreated} `@MainActor init(client: AppNorthClient, boards: [AppNorthBoard], copy: AppNorthCopy = .init(), theme: AppNorthTheme = .init(), onCreated: @escaping () -> Void = {})` ### `body` {#appnorthcreateideaview-body} `@MainActor var body: some View { get }` The content and behavior of the view. When you implement a custom view, you must implement a computed `body` property to provide the content for your view. Return a view that's composed of built-in views that SwiftUI provides, plus other composite views that you've already defined: struct MyView: View { var body: some View { Text("Hello, World!") } } For more information about composing views and a view hierarchy, see . ## AppNorthLinkKind {#appnorthlinkkind} Supported custom-link destinations: idea, private feedback, or the current chat. `enum AppNorthLinkKind` ### `idea` {#appnorthlinkkind-idea} `case idea` ### `feedback` {#appnorthlinkkind-feedback} `case feedback` ### `chat` {#appnorthlinkkind-chat} `case chat` The current identity's chat; the parsed link's `value` is `"current"`. ### `init(rawValue:)` {#appnorthlinkkind-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthLink {#appnorthlink} Parsed destination kind and decoded identifier. The link carries no authorization. `struct AppNorthLink` ### `kind` {#appnorthlink-kind} `let kind: AppNorthLinkKind` ### `value` {#appnorthlink-value} `let value: String` ## AppNorthDeepLinks {#appnorthdeeplinks} Custom-scheme URL builders and parser. Parsing does not validate the application scheme; the host must check it. See [deep links](/docs/deep-links). `enum AppNorthDeepLinks` ### `idea(scheme:handle:)` {#appnorthdeeplinks-idea-scheme-handle} `static func idea(scheme: String, handle: String) throws -> URL` Creates a custom-scheme idea URL and percent-encodes its handle as a single path component. ### `feedback(scheme:id:)` {#appnorthdeeplinks-feedback-scheme-id} `static func feedback(scheme: String, id: String) throws -> URL` Creates a custom-scheme private-report URL; it conveys no identity or access rights. ### `chat(scheme:)` {#appnorthdeeplinks-chat-scheme} `static func chat(scheme: String) throws -> URL` Creates `://appnorth/chat/current`. Invalid schemes throw `AppNorthError.invalidConfiguration`. The destination contains no user or conversation ID; your app presents chat with the intended client's current identity. ### `parse(_:)` {#appnorthdeeplinks-parse} `static func parse(_ url: URL) -> AppNorthLink?` Parses the host/path only. Reject unrecognized schemes in your app before calling this method. ## AppNorthIdeaDetailView {#appnorthideadetailview} Native idea detail and comments. Pass the actual board comment policy; the initializer defaults to `anyone`, while the server still enforces its policy. `@MainActor struct AppNorthIdeaDetailView` ### `init(client:idea:copy:theme:uiConfiguration:commentPolicy:)` {#appnorthideadetailview-init-client-idea-copy-theme-uiconfiguration-commentpolicy} `@MainActor init(client: AppNorthClient, idea: AppNorthIdea, copy: AppNorthCopy = .init(), theme: AppNorthTheme = .init(), uiConfiguration: AppNorthUIConfiguration = .init(), commentPolicy: AppNorthParticipation = .anyone)` ### `body` {#appnorthideadetailview-body} `@MainActor var body: some View { get }` The content and behavior of the view. When you implement a custom view, you must implement a computed `body` property to provide the content for your view. Return a view that's composed of built-in views that SwiftUI provides, plus other composite views that you've already defined: struct MyView: View { var body: some View { Text("Hello, World!") } } For more information about composing views and a view hierarchy, see . ## AppNorthInputLimits {#appnorthinputlimits} Client-side limits for idea titles (100), descriptions (5,000), and comments (2,000). Server validation remains authoritative. `enum AppNorthInputLimits` ### `ideaTitle` {#appnorthinputlimits-ideatitle} `static let ideaTitle: Int` ### `ideaDescription` {#appnorthinputlimits-ideadescription} `static let ideaDescription: Int` ### `comment` {#appnorthinputlimits-comment} `static let comment: Int` ## AppNorthParticipation {#appnorthparticipation} Per-action board policy. Local permission checks guide UI; server enforcement remains authoritative. `enum AppNorthParticipation` ### `anyone` {#appnorthparticipation-anyone} `case anyone` ### `identified` {#appnorthparticipation-identified} `case identified` ### `disabled` {#appnorthparticipation-disabled} `case disabled` ### `allowsParticipation(hasValidIdentity:)` {#appnorthparticipation-allowsparticipation-hasvalididentity} `func allowsParticipation(hasValidIdentity: Bool) -> Bool` Anyone returns true; identified returns the supplied identity flag; disabled returns false. ### `init(rawValue:)` {#appnorthparticipation-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthBoardStatus {#appnorthboardstatus} A board status definition. `isFinal` drives open/closed filtering; `color` is a server-provided hexadecimal color. `struct AppNorthBoardStatus` ### `id` {#appnorthboardstatus-id} `let id: String` The stable identity of the entity associated with this instance. ### `slug` {#appnorthboardstatus-slug} `let slug: String` ### `label` {#appnorthboardstatus-label} `let label: String` ### `color` {#appnorthboardstatus-color} `let color: String` ### `position` {#appnorthboardstatus-position} `let position: Int` ### `isDefault` {#appnorthboardstatus-isdefault} `let isDefault: Bool` ### `isFinal` {#appnorthboardstatus-isfinal} `let isFinal: Bool` ### `init(from:)` {#appnorthboardstatus-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthBoardPolicies {#appnorthboardpolicies} Independent policies for creating ideas, voting, and commenting. `struct AppNorthBoardPolicies` ### `submission` {#appnorthboardpolicies-submission} `let submission: AppNorthParticipation` ### `vote` {#appnorthboardpolicies-vote} `let vote: AppNorthParticipation` ### `comment` {#appnorthboardpolicies-comment} `let comment: AppNorthParticipation` ### `init(from:)` {#appnorthboardpolicies-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthBoard {#appnorthboard} A server-provided community board, including independent submission/vote/comment policies and status definitions. `struct AppNorthBoard` ### `id` {#appnorthboard-id} `let id: String` The stable identity of the entity associated with this instance. ### `slug` {#appnorthboard-slug} `let slug: String` ### `name` {#appnorthboard-name} `let name: String` ### `description` {#appnorthboard-description} `let description: String?` ### `position` {#appnorthboard-position} `let position: Int` ### `moderation` {#appnorthboard-moderation} `let moderation: String` ### `policies` {#appnorthboard-policies} `let policies: AppNorthBoardPolicies` ### `statuses` {#appnorthboard-statuses} `let statuses: [AppNorthBoardStatus]` ### `init(from:)` {#appnorthboard-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthIdeaStatus {#appnorthideastatus} The status attached to an idea: slug, display label, and hexadecimal color. `struct AppNorthIdeaStatus` ### `slug` {#appnorthideastatus-slug} `let slug: String` ### `label` {#appnorthideastatus-label} `let label: String` ### `color` {#appnorthideastatus-color} `let color: String` ### `init(from:)` {#appnorthideastatus-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthIdea {#appnorthidea} A community idea returned by the server. Use its ID or slug for detail and write operations. Counts and vote state are server-derived. `struct AppNorthIdea` ### `id` {#appnorthidea-id} `let id: String` The stable identity of the entity associated with this instance. ### `slug` {#appnorthidea-slug} `let slug: String` ### `boardSlug` {#appnorthidea-boardslug} `let boardSlug: String` ### `title` {#appnorthidea-title} `let title: String` ### `description` {#appnorthidea-description} `let description: String` ### `authorName` {#appnorthidea-authorname} `let authorName: String?` ### `status` {#appnorthidea-status} `let status: AppNorthIdeaStatus?` ### `voteCount` {#appnorthidea-votecount} `let voteCount: Int` ### `commentCount` {#appnorthidea-commentcount} `let commentCount: Int` ### `hasVoted` {#appnorthidea-hasvoted} `let hasVoted: Bool` ### `pinned` {#appnorthidea-pinned} `let pinned: Bool` ### `moderation` {#appnorthidea-moderation} `let moderation: String?` ### `createdAt` {#appnorthidea-createdat} `let createdAt: String` ### `lastActivityAt` {#appnorthidea-lastactivityat} `let lastActivityAt: String` ### `init(from:)` {#appnorthidea-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthWidgetConfig {#appnorthwidgetconfig} Public presentation configuration. Failure to load this resource need not block community reads. `struct AppNorthWidgetConfig` ### `branding` {#appnorthwidgetconfig-branding} `let branding: AppNorthWidgetConfig.Branding` ### `features` {#appnorthwidgetconfig-features} `let features: AppNorthWidgetConfig.Features?` ### `languages` {#appnorthwidgetconfig-languages} `let languages: [String]?` ### `init(from:)` {#appnorthwidgetconfig-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthWidgetConfig.Branding {#appnorthwidgetconfig-branding-type} Server-controlled branding flags. `watermark` controls the built-in attribution. `struct Branding` ### `watermark` {#appnorthwidgetconfig-branding-watermark} `let watermark: Bool` ### `init(from:)` {#appnorthwidgetconfig-branding-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthWidgetConfig.Features {#appnorthwidgetconfig-features-type} Optional feature configuration. Chat availability also depends on the current identity. `struct Features` ### `chat` {#appnorthwidgetconfig-features-chat} `let chat: AppNorthChatMode?` ### `init(from:)` {#appnorthwidgetconfig-features-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthChatMode {#appnorthchatmode} Chat participation mode. Unknown decoded values fall back to `disabled`. `enum AppNorthChatMode` ### `disabled` {#appnorthchatmode-disabled} `case disabled` ### `identified` {#appnorthchatmode-identified} `case identified` ### `anyone` {#appnorthchatmode-anyone} `case anyone` ### `init(from:)` {#appnorthchatmode-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ### `init(rawValue:)` {#appnorthchatmode-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthChatStatus {#appnorthchatstatus} Chat availability and unread state without reading the conversation. `struct AppNorthChatStatus` ### `available` {#appnorthchatstatus-available} `let available: Bool` ### `hasUnread` {#appnorthchatstatus-hasunread} `let hasUnread: Bool` ### `init(from:)` {#appnorthchatstatus-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthChatMessage {#appnorthchatmessage} A server message, optionally carrying an action card and its first-open timestamp. `struct AppNorthChatMessage` ### `kind` {#appnorthchatmessage-kind} `var kind: String?` ### `actionCard` {#appnorthchatmessage-actioncard} `var actionCard: AppNorthActionCard?` ### `openedAt` {#appnorthchatmessage-openedat} `var openedAt: String?` ### `tappedAt` {#appnorthchatmessage-tappedat} `var tappedAt: String?` First review-card tap recorded by the server; this does not confirm a submitted review. ### `id` {#appnorthchatmessage-id} `let id: String` The stable identity of the entity associated with this instance. ### `sender` {#appnorthchatmessage-sender} `let sender: AppNorthChatMessage.Sender` ### `body` {#appnorthchatmessage-body} `let body: String` SwiftUI view content. Present the view itself rather than invoking this property directly. ### `createdAt` {#appnorthchatmessage-createdat} `let createdAt: String` ### `init(from:)` {#appnorthchatmessage-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ### `images` {#appnorthchatmessage-images} `var images: [AppNorthChatImage]?` Optional for backward compatibility with messages from older servers. URLs are private and expire; reload the thread to refresh them. ## AppNorthChatMessage.Sender {#appnorthchatmessage-sender-type} Conversation side. Unknown sender values decode as `team` for forward compatibility. `enum Sender` ### `user` {#appnorthchatmessage-sender-user} `case user` ### `team` {#appnorthchatmessage-sender-team} `case team` ### `init(from:)` {#appnorthchatmessage-sender-init-from} `init(from decoder: any Decoder) throws` Future sender kinds render as the team side instead of failing. ### `init(rawValue:)` {#appnorthchatmessage-sender-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthChatThread {#appnorthchatthread} Conversation availability and the returned messages. A request with an `after` cursor can contain only newer messages. `struct AppNorthChatThread` ### `available` {#appnorthchatthread-available} `let available: Bool` ### `messages` {#appnorthchatthread-messages} `let messages: [AppNorthChatMessage]` ### `init(from:)` {#appnorthchatthread-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthComment {#appnorthcomment} A community comment. `official` indicates an official reply when provided by the server. `struct AppNorthComment` ### `id` {#appnorthcomment-id} `let id: String` The stable identity of the entity associated with this instance. ### `authorName` {#appnorthcomment-authorname} `let authorName: String?` ### `body` {#appnorthcomment-body} `let body: String` SwiftUI view content. Present the view itself rather than invoking this property directly. ### `createdAt` {#appnorthcomment-createdat} `let createdAt: String` ### `official` {#appnorthcomment-official} `let official: Bool?` ### `init(from:)` {#appnorthcomment-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthIdeaSort {#appnorthideasort} Server sorting choices: most voted (`top`), newest (`recent`), or most discussed (`discussed`). `enum AppNorthIdeaSort` ### `top` {#appnorthideasort-top} `case top` ### `recent` {#appnorthideasort-recent} `case recent` ### `discussed` {#appnorthideasort-discussed} `case discussed` ### `init(rawValue:)` {#appnorthideasort-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthPage {#appnorthpage} One page of typed items and an opaque optional next cursor. A nil cursor ends pagination. `struct AppNorthPage where Element : Sendable` ### `items` {#appnorthpage-items} `let items: [Element]` ### `nextCursor` {#appnorthpage-nextcursor} `let nextCursor: String?` ## AppNorthVote {#appnorthvote} The resulting vote state and count. `applied` distinguishes a state change from a repeated request. `struct AppNorthVote` ### `voted` {#appnorthvote-voted} `let voted: Bool` ### `voteCount` {#appnorthvote-votecount} `let voteCount: Int` ### `applied` {#appnorthvote-applied} `let applied: Bool` ### `init(from:)` {#appnorthvote-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthCreation {#appnorthcreation} A created value plus `moderated`, which indicates that publication may await approval. `struct AppNorthCreation where Element : Sendable` ### `value` {#appnorthcreation-value} `let value: Element` ### `moderated` {#appnorthcreation-moderated} `let moderated: Bool` ## AppNorthFeedbackType {#appnorthfeedbacktype} Private report category: problem, suggestion, or comment. `enum AppNorthFeedbackType` ### `problem` {#appnorthfeedbacktype-problem} `case problem` ### `suggestion` {#appnorthfeedbacktype-suggestion} `case suggestion` ### `comment` {#appnorthfeedbacktype-comment} `case comment` ### `init(rawValue:)` {#appnorthfeedbacktype-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthViewport {#appnorthviewport} Positive width and height for the reported screen. The server accepts dimensions up to 20,000. `struct AppNorthViewport` ### `width` {#appnorthviewport-width} `let width: Int` ### `height` {#appnorthviewport-height} `let height: Int` ### `init(width:height:)` {#appnorthviewport-init-width-height} `init(width: Int, height: Int)` ### `init(from:)` {#appnorthviewport-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthJSONValue {#appnorthjsonvalue} Codable JSON values for diagnostic metadata. Sanitize content before sending it. `indirect enum AppNorthJSONValue` ### `string(_:)` {#appnorthjsonvalue-string} `case string(String)` ### `number(_:)` {#appnorthjsonvalue-number} `case number(Double)` ### `bool(_:)` {#appnorthjsonvalue-bool} `case bool(Bool)` ### `object(_:)` {#appnorthjsonvalue-object} `case object([String : AppNorthJSONValue])` ### `array(_:)` {#appnorthjsonvalue-array} `case array([AppNorthJSONValue])` ### `null` {#appnorthjsonvalue-null} `case null` ### `init(from:)` {#appnorthjsonvalue-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ### `encode(to:)` {#appnorthjsonvalue-encode-to} `func encode(to encoder: any Encoder) throws` Encodes the value using its wire representation. ## AppNorthDiagnosticCategory {#appnorthdiagnosticcategory} Diagnostic kind: console, error, network, or breadcrumb. `enum AppNorthDiagnosticCategory` ### `console` {#appnorthdiagnosticcategory-console} `case console` ### `error` {#appnorthdiagnosticcategory-error} `case error` ### `network` {#appnorthdiagnosticcategory-network} `case network` ### `breadcrumb` {#appnorthdiagnosticcategory-breadcrumb} `case breadcrumb` ### `init(rawValue:)` {#appnorthdiagnosticcategory-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthDiagnosticLevel {#appnorthdiagnosticlevel} Diagnostic severity: debug, info, warn, or error. `enum AppNorthDiagnosticLevel` ### `debug` {#appnorthdiagnosticlevel-debug} `case debug` ### `info` {#appnorthdiagnosticlevel-info} `case info` ### `warn` {#appnorthdiagnosticlevel-warn} `case warn` ### `error` {#appnorthdiagnosticlevel-error} `case error` ### `init(rawValue:)` {#appnorthdiagnosticlevel-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthFeedbackDiagnostic {#appnorthfeedbackdiagnostic} One host-sanitized diagnostic with optional severity, timestamp, and JSON metadata. Requires the host’s collection consent. `struct AppNorthFeedbackDiagnostic` ### `category` {#appnorthfeedbackdiagnostic-category} `let category: AppNorthDiagnosticCategory` ### `level` {#appnorthfeedbackdiagnostic-level} `let level: AppNorthDiagnosticLevel?` ### `message` {#appnorthfeedbackdiagnostic-message} `let message: String` ### `timestamp` {#appnorthfeedbackdiagnostic-timestamp} `let timestamp: String?` ### `metadata` {#appnorthfeedbackdiagnostic-metadata} `let metadata: [String : AppNorthJSONValue]?` ### `init(category:level:message:timestamp:metadata:)` {#appnorthfeedbackdiagnostic-init-category-level-message-timestamp-metadata} `init(category: AppNorthDiagnosticCategory, level: AppNorthDiagnosticLevel? = nil, message: String, timestamp: String? = nil, metadata: [String : AppNorthJSONValue]? = nil)` ### `init(from:)` {#appnorthfeedbackdiagnostic-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthDeviceType {#appnorthdevicetype} Device context values accepted by the shared wire contract. Their presence is not a promise of SDK support for other platforms; this guide covers iOS. `enum AppNorthDeviceType` ### `phone` {#appnorthdevicetype-phone} `case phone` ### `tablet` {#appnorthdevicetype-tablet} `case tablet` ### `desktop` {#appnorthdevicetype-desktop} `case desktop` ### `tv` {#appnorthdevicetype-tv} `case tv` ### `unknown` {#appnorthdevicetype-unknown} `case unknown` ### `init(rawValue:)` {#appnorthdevicetype-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthDeviceContext {#appnorthdevicecontext} Optional bounded device/app context. Strings are flattened and truncated to 120 Swift characters. No automatic device collection or secret redaction is performed. `struct AppNorthDeviceContext` ### `appName` {#appnorthdevicecontext-appname} `let appName: String?` ### `appVersion` {#appnorthdevicecontext-appversion} `let appVersion: String?` ### `buildVersion` {#appnorthdevicecontext-buildversion} `let buildVersion: String?` ### `deviceModel` {#appnorthdevicecontext-devicemodel} `let deviceModel: String?` ### `deviceType` {#appnorthdevicecontext-devicetype} `let deviceType: AppNorthDeviceType?` ### `operatingSystem` {#appnorthdevicecontext-operatingsystem} `let operatingSystem: String?` ### `systemVersion` {#appnorthdevicecontext-systemversion} `let systemVersion: String?` ### `init(appName:appVersion:buildVersion:deviceModel:deviceType:operatingSystem:systemVersion:)` {#appnorthdevicecontext-init-appname-appversion-buildversion-devicemodel-devicetype-operatingsystem-systemversion} `init(appName: String? = nil, appVersion: String? = nil, buildVersion: String? = nil, deviceModel: String? = nil, deviceType: AppNorthDeviceType? = nil, operatingSystem: String? = nil, systemVersion: String? = nil)` ### `init(from:)` {#appnorthdevicecontext-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthCreateFeedback {#appnorthcreatefeedback} Private report input. Required fields are type, message, HTTP(S) page URL, and viewport; optional diagnostics and screenshot must already be consented and sanitized. See [private feedback](/docs/private-feedback). `struct AppNorthCreateFeedback` ### `type` {#appnorthcreatefeedback-type} `let type: AppNorthFeedbackType` ### `message` {#appnorthcreatefeedback-message} `let message: String` ### `email` {#appnorthcreatefeedback-email} `let email: String?` ### `pageURL` {#appnorthcreatefeedback-pageurl} `let pageURL: URL` ### `pageTitle` {#appnorthcreatefeedback-pagetitle} `let pageTitle: String?` ### `locale` {#appnorthcreatefeedback-locale} `let locale: String?` ### `viewport` {#appnorthcreatefeedback-viewport} `let viewport: AppNorthViewport` ### `userAgent` {#appnorthcreatefeedback-useragent} `let userAgent: String?` ### `deviceContext` {#appnorthcreatefeedback-devicecontext} `let deviceContext: AppNorthDeviceContext?` ### `diagnostics` {#appnorthcreatefeedback-diagnostics} `let diagnostics: [AppNorthFeedbackDiagnostic]` ### `screenshot` {#appnorthcreatefeedback-screenshot} `let screenshot: String?` ### `init(type:message:email:pageURL:pageTitle:locale:viewport:userAgent:deviceContext:diagnostics:screenshot:)` {#appnorthcreatefeedback-init-type-message-email-pageurl-pagetitle-locale-viewport-useragent-devicecontext-diagnostics-screenshot} `init(type: AppNorthFeedbackType, message: String, email: String? = nil, pageURL: URL, pageTitle: String? = nil, locale: String? = nil, viewport: AppNorthViewport, userAgent: String? = nil, deviceContext: AppNorthDeviceContext? = nil, diagnostics: [AppNorthFeedbackDiagnostic] = [], screenshot: String? = nil)` ### `init(from:)` {#appnorthcreatefeedback-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthFeedbackReceipt {#appnorthfeedbackreceipt} Submission receipt. `screenshotStored` reports actual attachment retention and `deduplicated` identifies a replayed creation. `struct AppNorthFeedbackReceipt` ### `id` {#appnorthfeedbackreceipt-id} `let id: String` ### `correlationId` {#appnorthfeedbackreceipt-correlationid} `let correlationId: String` ### `screenshotStored` {#appnorthfeedbackreceipt-screenshotstored} `let screenshotStored: Bool` ### `deduplicated` {#appnorthfeedbackreceipt-deduplicated} `let deduplicated: Bool` ### `init(from:)` {#appnorthfeedbackreceipt-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthFeedbackReply {#appnorthfeedbackreply} A public staff reply within the author’s private report thread. `struct AppNorthFeedbackReply` ### `id` {#appnorthfeedbackreply-id} `let id: String` The stable identity of the entity associated with this instance. ### `message` {#appnorthfeedbackreply-message} `let message: String` ### `createdAt` {#appnorthfeedbackreply-createdat} `let createdAt: String` ### `init(from:)` {#appnorthfeedbackreply-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthFeedbackThread {#appnorthfeedbackthread} The verified author’s report and public staff replies. Excludes contact data, screenshots, diagnostics, and internal notes. `struct AppNorthFeedbackThread` ### `id` {#appnorthfeedbackthread-id} `let id: String` The stable identity of the entity associated with this instance. ### `type` {#appnorthfeedbackthread-type} `let type: AppNorthFeedbackType` ### `status` {#appnorthfeedbackthread-status} `let status: String` ### `message` {#appnorthfeedbackthread-message} `let message: String` ### `createdAt` {#appnorthfeedbackthread-createdat} `let createdAt: String` ### `updatedAt` {#appnorthfeedbackthread-updatedat} `let updatedAt: String` ### `replies` {#appnorthfeedbackthread-replies} `let replies: [AppNorthFeedbackReply]` ### `init(from:)` {#appnorthfeedbackthread-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthOfflineQueueStorage {#appnorthofflinequeuestorage} Host-provided queue persistence. Keep it separate from cache storage and scope retention to your account lifecycle. `protocol AppNorthOfflineQueueStorage : Sendable` ### `load()` {#appnorthofflinequeuestorage-load} `func load() async throws -> Data?` Returns stored bytes, or nil when no snapshot exists. Storage failures may throw. ### `save(_:)` {#appnorthofflinequeuestorage-save} `func save(_ data: Data?) async throws` Replaces stored bytes; nil means remove the persisted snapshot. ## AppNorthOfflineQueueError {#appnorthofflinequeueerror} Queue errors. `full` means another write could not be queued without exceeding capacity. `enum AppNorthOfflineQueueError` ### `full` {#appnorthofflinequeueerror-full} `case full` ## AppNorthOfflineQueue {#appnorthofflinequeue} Opt-in actor for network-failed idea, comment, and feedback writes. Default capacity is 50. Storage and flush scheduling belong to the host; votes and chat are not queue operations. `actor AppNorthOfflineQueue` ### `init(storage:limit:)` {#appnorthofflinequeue-init-storage-limit} `init(storage: any AppNorthOfflineQueueStorage, limit: Int = 50)` ### `submitIdea(using:board:title:description:authorName:idempotencyKey:)` {#appnorthofflinequeue-submitidea-using-board-title-description-authorname-idempotencykey} `@discardableResult func submitIdea(using client: AppNorthClient, board: String, title: String, description: String = "", authorName: String? = nil, idempotencyKey: String = UUID().uuidString) async throws -> Bool` Attempts the write immediately; returns true on success, false when a network failure is queued. Other errors propagate. Preserve the idempotency key on replay. ### `submitComment(using:handle:body:authorName:idempotencyKey:)` {#appnorthofflinequeue-submitcomment-using-handle-body-authorname-idempotencykey} `@discardableResult func submitComment(using client: AppNorthClient, handle: String, body: String, authorName: String? = nil, idempotencyKey: String = UUID().uuidString) async throws -> Bool` Attempts the write immediately; returns true on success, false when a network failure is queued. Other errors propagate. Preserve the idempotency key on replay. ### `submitFeedback(using:input:idempotencyKey:)` {#appnorthofflinequeue-submitfeedback-using-input-idempotencykey} `@discardableResult func submitFeedback(using client: AppNorthClient, input: AppNorthCreateFeedback, idempotencyKey: String = UUID().uuidString) async throws -> Bool` Attempts the write immediately; returns true on success, false when a network failure is queued. Other errors propagate. Preserve the idempotency key on replay. ### `pendingCount()` {#appnorthofflinequeue-pendingcount} `func pendingCount() async throws -> Int` Loads storage if needed and returns the total number of queued entries across scopes. ### `flush(using:)` {#appnorthofflinequeue-flush-using} `func flush(using client: AppNorthClient) async throws` Replays only matching identity scopes. Stops on network failure; other errors throw and leave the entry pending. ## AppNorthPaywallProduct {#appnorthpaywallproduct} Product metadata: localized display price, ISO-8601 billing period, and optional trial duration. Use `P0D` for one-time products. `struct AppNorthPaywallProduct` ### `id` {#appnorthpaywallproduct-id} `let id: String` ### `price` {#appnorthpaywallproduct-price} `let price: String` Localized display price, including currency. ### `period` {#appnorthpaywallproduct-period} `let period: String` ISO-8601 duration. Use P0D for a one-time product. ### `trial` {#appnorthpaywallproduct-trial} `let trial: String?` ### `init(id:price:period:trial:)` {#appnorthpaywallproduct-init-id-price-period-trial} `init(id: String, price: String, period: String, trial: String? = nil)` ### `init(from:)` {#appnorthpaywallproduct-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthPaywall {#appnorthpaywall} One offering in the device catalog. Title is team-only; the initializer sets source to `revenuecat`. `presentable` and `available` both gate opening. `struct AppNorthPaywall` ### `id` {#appnorthpaywall-id} `let id: String` The stable identity of the entity associated with this instance. ### `title` {#appnorthpaywall-title} `let title: String` Team-only label; never rendered on the customer's card. ### `source` {#appnorthpaywall-source} `let source: String` ### `presentable` {#appnorthpaywall-presentable} `let presentable: Bool` ### `available` {#appnorthpaywall-available} `var available: Bool` ### `products` {#appnorthpaywall-products} `let products: [AppNorthPaywallProduct]` ### `init(id:title:presentable:available:products:)` {#appnorthpaywall-init-id-title-presentable-available-products} `init(id: String, title: String, presentable: Bool, available: Bool = true, products: [AppNorthPaywallProduct] = [])` ### `init(from:)` {#appnorthpaywall-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthSubscriptionMetadata {#appnorthsubscriptionmetadata} Device-reported subscription state, separate from signed identity. Dates use YYYY-MM-DD; renews and expires dates are mutually exclusive. Sandbox sets environment to `sandbox`. `struct AppNorthSubscriptionMetadata` ### `subscription_status` {#appnorthsubscriptionmetadata-subscription-status} `let subscription_status: AppNorthSubscriptionMetadata.Status` ### `plan` {#appnorthsubscriptionmetadata-plan} `let plan: String?` ### `renews_on` {#appnorthsubscriptionmetadata-renews-on} `let renews_on: String?` ### `expires_on` {#appnorthsubscriptionmetadata-expires-on} `let expires_on: String?` ### `environment` {#appnorthsubscriptionmetadata-environment} `let environment: String?` ### `init(status:plan:renewsOn:expiresOn:sandbox:)` {#appnorthsubscriptionmetadata-init-status-plan-renewson-expireson-sandbox} `init(status: AppNorthSubscriptionMetadata.Status, plan: String? = nil, renewsOn: String? = nil, expiresOn: String? = nil, sandbox: Bool = false)` ### `init(from:)` {#appnorthsubscriptionmetadata-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthSubscriptionMetadata.Status {#appnorthsubscriptionmetadata-status-type} Whether the reported subscription is active or absent. This device metadata is not a trusted entitlement check. `enum Status` ### `active` {#appnorthsubscriptionmetadata-status-active} `case active` ### `none` {#appnorthsubscriptionmetadata-status-none} `case none` ### `init(rawValue:)` {#appnorthsubscriptionmetadata-status-init-rawvalue} `init?(rawValue: String)` Creates a new instance with the specified raw value. If there is no value of the type that corresponds with the specified raw value, this initializer returns `nil`. For example: enum PaperSize: String { case A4, A5, Letter, Legal } print(PaperSize(rawValue: "Legal")) // Prints "Optional(PaperSize.Legal)" print(PaperSize(rawValue: "Tabloid")) // Prints "nil" - Parameter rawValue: The raw value to use for the new instance. ## AppNorthPaywallSnapshot {#appnorthpaywallsnapshot} Validated catalog and optional plan metadata. Limits: 20 paywalls, 10 products per paywall, 40 products total. Validation also runs during encoding. `struct AppNorthPaywallSnapshot` ### `paywalls` {#appnorthpaywallsnapshot-paywalls} `let paywalls: [AppNorthPaywall]` ### `plan` {#appnorthpaywallsnapshot-plan} `let plan: AppNorthSubscriptionMetadata?` ### `init(paywalls:plan:)` {#appnorthpaywallsnapshot-init-paywalls-plan} `init(paywalls: [AppNorthPaywall], plan: AppNorthSubscriptionMetadata? = nil) throws` ### `validate()` {#appnorthpaywallsnapshot-validate} `func validate() throws` Throws invalidConfiguration for invalid catalog or metadata fields; never silently trims the catalog. ### `encode(to:)` {#appnorthpaywallsnapshot-encode-to} `func encode(to encoder: any Encoder) throws` Encodes the value using its wire representation. ### `init(from:)` {#appnorthpaywallsnapshot-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthActionCard {#appnorthactioncard} Server card envelope. Only offer cards are supported by the paywall controller; unknown kinds remain disabled. `titleAtSend` is a team label, not customer-facing copy. `struct AppNorthActionCard` ### `kind` {#appnorthactioncard-kind} `let kind: String` ### `paywallId` {#appnorthactioncard-paywallid} `let paywallId: String?` ### `titleAtSend` {#appnorthactioncard-titleatsend} `let titleAtSend: String` ### `buttonLabel` {#appnorthactioncard-buttonlabel} `let buttonLabel: String` ### `body` {#appnorthactioncard-body} `let body: String?` SwiftUI view content. Present the view itself rather than invoking this property directly. ### `available` {#appnorthactioncard-available} `var available: Bool` ### `init(from:)` {#appnorthactioncard-init-from} `init(from decoder: any Decoder) throws` Creates a new instance by decoding from the given decoder. This initializer throws an error if reading from the decoder fails, or if the data read is corrupted or otherwise invalid. - Parameter decoder: The decoder to read data from. ## AppNorthPaywalls {#appnorthpaywalls} Main-actor paywall controller; retain one per chat identity. Configure catalog, availability, and presentation before passing it to chat. See [paywall cards](/docs/paywall-cards). `@MainActor final class AppNorthPaywalls` ### `snapshot` {#appnorthpaywalls-snapshot} `@Published @MainActor var snapshot: AppNorthPaywallSnapshot? { get }` ### `availabilityProvider` {#appnorthpaywalls-availabilityprovider} `@MainActor var availabilityProvider: ((String) async -> Bool)?` ### `onPaywall` {#appnorthpaywalls-onpaywall} `@MainActor var onPaywall: ((String) -> Void)?` ### `onOfferOpened` {#appnorthpaywalls-onofferopened} `@MainActor var onOfferOpened: ((String, String) -> Void)?` Called once per card in this controller. Server receipts also deduplicate across app launches. This signals a tap, not a completed purchase. ### `init(client:)` {#appnorthpaywalls-init-client} `@MainActor init(client: AppNorthClient)` ### `setPaywalls(_:plan:)` {#appnorthpaywalls-setpaywalls-plan} `@MainActor func setPaywalls(_ values: [AppNorthPaywall], plan: AppNorthSubscriptionMetadata? = nil) throws` Validates and replaces the complete catalog and optional metadata. Does not send until refresh. ### `refreshPaywalls(invalidate:)` {#appnorthpaywalls-refreshpaywalls-invalidate} `@MainActor func refreshPaywalls(invalidate: Bool = false) async throws` Re-evaluates availability, reports the snapshot, and retries pending receipts. Concurrent refreshes coalesce; invalidate after state changes. ### `isAvailable(_:)` {#appnorthpaywalls-isavailable} `@MainActor func isAvailable(_ card: AppNorthActionCard) -> Bool` Requires an offer card, server availability, a matching identity, a presentation callback, and a presentable available snapshot entry. ### `open(_:messageId:alreadyOpened:)` {#appnorthpaywalls-open-messageid-alreadyopened} `@MainActor func open(_ card: AppNorthActionCard, messageId: String, alreadyOpened: Bool = false) async` Invokes presentation for an available card and reports its first tap. Already-opened cards may present again without another first-open event. ## AppNorthReadCacheStorage {#appnorthreadcachestorage} Host-provided cache persistence. Use a different storage instance or file from the offline queue. `protocol AppNorthReadCacheStorage : Sendable` ### `load()` {#appnorthreadcachestorage-load} `func load() async throws -> Data?` Returns stored bytes, or nil when no snapshot exists. Storage failures may throw. ### `save(_:)` {#appnorthreadcachestorage-save} `func save(_ data: Data?) async throws` Replaces stored bytes; nil means remove the persisted snapshot. ## AppNorthReadCache {#appnorthreadcache} Opt-in actor storing boards and idea arrays. Default TTL is 300 seconds; stale or missing entries return nil. See [offline and cache](/docs/offline-and-cache). `actor AppNorthReadCache` ### `init(storage:ttl:clock:)` {#appnorthreadcache-init-storage-ttl-clock} `init(storage: any AppNorthReadCacheStorage, ttl: TimeInterval = 300, clock: @escaping @Sendable () -> Date = { Date() })` ### `boards(scope:)` {#appnorthreadcache-boards-scope} `func boards(scope: String) async throws -> [AppNorthBoard]?` Returns cached values only while fresh under the configured TTL; otherwise nil. ### `ideas(scope:query:)` {#appnorthreadcache-ideas-scope-query} `func ideas(scope: String, query: String) async throws -> [AppNorthIdea]?` Returns cached values only while fresh under the configured TTL; otherwise nil. ### `storeBoards(_:scope:)` {#appnorthreadcache-storeboards-scope} `func storeBoards(_ boards: [AppNorthBoard], scope: String) async throws` Stores the supplied values with the current timestamp under the specified scope and query, then persists them. ### `storeIdeas(_:scope:query:)` {#appnorthreadcache-storeideas-scope-query} `func storeIdeas(_ ideas: [AppNorthIdea], scope: String, query: String) async throws` Stores the supplied values with the current timestamp under the specified scope and query, then persists them. ### `clear(scope:)` {#appnorthreadcache-clear-scope} `func clear(scope: String) async throws` Removes the scope’s boards and all idea queries in that scope, then persists the new snapshot. ## AppNorthTheme {#appnorththeme} Native view colors. Primary defaults to the host accent; optional secondary, tertiary, and text pairs follow appearance. See [appearance](/docs/appearance). `struct AppNorthTheme` ### `primary` {#appnorththeme-primary} `var primary: Color` ### `secondary` {#appnorththeme-secondary} `var secondary: AppNorthColorPair?` ### `tertiary` {#appnorththeme-tertiary} `var tertiary: AppNorthColorPair?` ### `text` {#appnorththeme-text} `var text: AppNorthColorPair?` ### `init(primary:secondary:tertiary:text:)` {#appnorththeme-init-primary-secondary-tertiary-text} `init(primary: Color = Self.defaultPrimary, secondary: AppNorthColorPair? = nil, tertiary: AppNorthColorPair? = nil, text: AppNorthColorPair? = nil)` ### `defaultPrimary` {#appnorththeme-defaultprimary} `static var defaultPrimary: Color { get }` ## AppNorthTranslateButton {#appnorthtranslatebutton} Idea translation control policy. Translation UI requires iOS 18 or later even when set to `always`. `enum AppNorthTranslateButton` ### `automatic` {#appnorthtranslatebutton-automatic} `case automatic` ### `always` {#appnorthtranslatebutton-always} `case always` ### `hide` {#appnorthtranslatebutton-hide} `case hide` ## AppNorthUpvoteIcon {#appnorthupvoteicon} Built-in vote icon options or a host-provided SF Symbol name. `enum AppNorthUpvoteIcon` ### `systemName(_:)` {#appnorthupvoteicon-systemname} `case systemName(String)` ### `thumbsUp` {#appnorthupvoteicon-thumbsup} `case thumbsUp` ### `arrowUp` {#appnorthupvoteicon-arrowup} `case arrowUp` ### `chevronUp` {#appnorthupvoteicon-chevronup} `case chevronUp` ## AppNorthUIConfiguration {#appnorthuiconfiguration} Native view behavior. Defaults: open/closed filter on, arrow-up vote icon, automatic translation, chat button on. `struct AppNorthUIConfiguration` ### `showsOpenClosedFilter` {#appnorthuiconfiguration-showsopenclosedfilter} `var showsOpenClosedFilter: Bool` ### `upvoteIcon` {#appnorthuiconfiguration-upvoteicon} `var upvoteIcon: AppNorthUpvoteIcon` ### `translateButton` {#appnorthuiconfiguration-translatebutton} `var translateButton: AppNorthTranslateButton` ### `showsChatButton` {#appnorthuiconfiguration-showschatbutton} `var showsChatButton: Bool` Shows the floating chat button when chat is enabled for the project. ### `init(showsOpenClosedFilter:upvoteIcon:translateButton:showsChatButton:)` {#appnorthuiconfiguration-init-showsopenclosedfilter-upvoteicon-translatebutton-showschatbutton} `init(showsOpenClosedFilter: Bool = true, upvoteIcon: AppNorthUpvoteIcon = .arrowUp, translateButton: AppNorthTranslateButton = .automatic, showsChatButton: Bool = true)` ## AppNorthIdentityProperty {#appnorthidentityproperty} A scalar property included in a v2 identity signature. Preserve the exact backend-issued values. `enum AppNorthIdentityProperty` ### `string(_:)` {#appnorthidentityproperty-string} `case string(String)` ### `number(_:)` {#appnorthidentityproperty-number} `case number(Double)` ### `boolean(_:)` {#appnorthidentityproperty-boolean} `case boolean(Bool)` ### `encode(to:)` {#appnorthidentityproperty-encode-to} `func encode(to encoder: any Encoder) throws` Encodes the value using its wire representation. ## AppNorthIdentityCredential {#appnorthidentitycredential} An in-memory credential issued by your authenticated backend. Construction validates shape; only the server verifies the signature. See [verified identity](/docs/verified-identity). `struct AppNorthIdentityCredential` ### `email` {#appnorthidentitycredential-email} `let email: String` ### `expiresAt` {#appnorthidentitycredential-expiresat} `let expiresAt: Int64` ### `signature` {#appnorthidentitycredential-signature} `let signature: String` ### `properties` {#appnorthidentitycredential-properties} `let properties: [String : AppNorthIdentityProperty]?` ### `init(email:expiresAt:signature:properties:)` {#appnorthidentitycredential-init-email-expiresat-signature-properties} `init(email: String, expiresAt: Int64, signature: String, properties: [String : AppNorthIdentityProperty]? = nil) throws` ### `isValid(at:)` {#appnorthidentitycredential-isvalid-at} `func isValid(at date: Date = Date()) -> Bool` Checks expiry only. A valid return value does not authenticate the signature. ## `captureAppNorthScreenshot(consent:adapter:redactor:)` {#captureappnorthscreenshot-consent-adapter-redactor} `func captureAppNorthScreenshot(consent: Bool, adapter: (any AppNorthScreenshotCapturing)?, redactor: (any AppNorthScreenshotRedacting)?) async -> String?` Captures only with consent and both adapters. Returns nil on any failure, unsupported data URL, oversize data, or unchanged redactor output. See [consented capture](/docs/private-feedback#capture). ## RevenueCat helper {#appnorthrevenuecatpaywalls} `@MainActor public final class AppNorthRevenueCatPaywalls` This optional source helper lives outside the SDK binary. Copy `Helpers/AppNorthRevenueCatPaywalls.swift` into your app target and add RevenueCat dependencies as described in [paywall cards](/docs/paywall-cards#helper). ### `init(client:syncsSubscriptionMetadata:presenter:)` {#appnorthrevenuecatpaywalls-init-client-syncssubscriptionmetadata-presenter} `init(client: AppNorthClient, syncsSubscriptionMetadata: Bool = true, presenter: (() -> UIViewController?)? = nil)` Create after configuring Purchases and the matching AppNorth identity. Starts observation and the first reload; retain for the session. ### `paywalls` {#appnorthrevenuecatpaywalls-paywalls} `let paywalls: AppNorthPaywalls` Pass this controller to AppNorthChatView or AppNorthView. ### `onError` {#appnorthrevenuecatpaywalls-onerror} `var onError: ((Error) -> Void)?` Receives offering-load or synchronization failures. Avoid logging identity or customer details. ### `reload()` {#appnorthrevenuecatpaywalls-reload} `func reload()` Loads all offerings and customer information, then refreshes availability and snapshot metadata. Concurrent reloads coalesce. ### `stop()` {#appnorthrevenuecatpaywalls-stop} `func stop()` Stops observation/reload tasks, removes the foreground observer, disables presentation, dismisses the paywall, and clears the catalog. Call before changing accounts; create a new helper for the next session. ## Verify {#verify} 1. Import `AppNorth` in your app target and compile the example above with a configured client. 2. Use public initializers exactly as listed; keep main-actor objects on the main actor. 3. Run the verification steps in the relevant task guide against your own project. A successful compile does not verify server policy, account ownership, or purchase configuration. ## Review request card activity {#review-request-activity} `AppNorthClient.markActionCardTapped(_:) async throws` reports the first tap on a `review_request` card. `markActionCardOpened(_:)` reports its first display. Both use stable, separate idempotency keys and the current chat identity; neither reports a submitted review or guarantees that StoreKit displayed a prompt. `AppNorthChatMessage.tappedAt` and `AppNorthChatEntry.tappedAt` are optional server timestamp strings, alongside `openedAt`. `AppNorthChatModel` updates the existing entry when polling returns new card activity. `AppNorthChatView` presents review cards without a paywall controller and uses `AppStore.requestReview(in:)` on iOS 16+. See [review requests in support chat](/docs/support-chat#review-requests) for sending, automatic resolution settings, and Apple's presentation limits. ## AppNorthChatImage {#appnorthchatimage} `struct AppNorthChatImage: Codable, Hashable, Identifiable, Sendable` | Property | Type | Meaning | | -------- | -------- | ------------------------------------------------------- | | `id` | `String` | Image identifier. | | `url` | `URL?` | Private, expiring URL; nil when storage is unavailable. | | `width` | `Int` | Sanitized image width in pixels. | | `height` | `Int` | Sanitized image height in pixels. | ## AppNorthChatImageUpload {#appnorthchatimageupload} `struct AppNorthChatImageUpload: Codable, Hashable, Sendable` | Property | Type | Meaning | | ------------ | ------------------- | ------------------------------------------------- | | `imageToken` | `String` | App- and sender-bound token, valid for 24 hours. | | `image` | `AppNorthChatImage` | Sanitized preview; its URL expires in 10 minutes. | Use `AppNorthChatModel.send(_:images:)` to queue completed uploads with optimistic bubbles, or pass their tokens directly to `AppNorthClient.sendChatMessage`. --- # Errors The Swift SDK throws `AppNorthError`. Server responses use stable error codes for program logic; show a helpful explanation to the user instead of displaying the code alone. ## Swift error cases {#swift} | Case | Meaning and recovery | | ----------------------------------- | ---------------------------------------------------------------------------------------------- | | `invalidConfiguration(String)` | Fix the API URL, public App ID or origin before constructing the client | | `invalidIdentityCredential(String)` | Obtain a valid credential from your authenticated backend | | `invalidResponse` | The transport did not return an HTTP response; inspect your transport integration | | `network(String)` | Connectivity failed; retry with a bounded delay | | `server(status:code:fields:)` | Inspect the HTTP status, stable code and optional field errors | | `decoding(String)` | The success response could not be decoded; check SDK/server compatibility and the API base URL | `AppNorthOfflineQueueError` separately reports persisted-queue failures. See [Offline and cache](/docs/offline-and-cache) for storage and replay behavior. ## Public API failures {#server} | Code or response | Action | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `INVALID_APP_ID`, `PROJECT_NOT_FOUND` | Copy the public App ID from the correct app's installation page | | `APP_ID_MISMATCH` | Use the same public App ID in the feedback body and query | | `INVALID_JSON`, `UNSUPPORTED_MEDIA_TYPE` | Send valid JSON with `Content-Type: application/json` | | `INVALID_IDEMPOTENCY_KEY` | Supply a key in the format documented in Limits | | `IDEMPOTENCY_CONFLICT` | The key was already used for a different payload; preserve the original key for retries and use a new one for a new intent | | `INVALID_SCREENSHOT` | Validate the image data URL and screenshot limits | | `INVALID_PAYLOAD`, `INVALID_QUERY` | Correct the fields or query parameters; do not blindly retry | | `ORIGIN_NOT_ALLOWED` | Correct the configured origin and app's allowed domain | | `IDENTITY_REQUIRED` | Supply a credential for an identified-only operation | | `INVALID_IDENTITY` | Renew the signed credential; check expiry and signing inputs on your backend | | `IDENTITY_NOT_CONFIGURED` | Configure identity verification for this app on the server | | `IDENTITY_EMAIL_MISMATCH` | Match the feedback email to the verified identity | | `CHAT_DISABLED` | Enable the intended chat mode in the app's settings | | `FEEDBACK_NOT_FOUND` | Treat the private thread as unavailable to this identity | | `RATE_LIMITED` / HTTP 429 | Wait for `Retry-After` when provided and reduce parallel requests | | HTTP 413 | Reduce the request body, screenshot or diagnostics | | HTTP 5xx | Retry recoverable failures with bounded backoff; investigate persistent failures | Keep the same idempotency key when retrying the same mutation. Do not change its payload and reuse that key. Private resources can return 404 without disclosing another user's data. ## Dashboard and provider errors {#integrations} `UNAUTHORIZED` requires a dashboard session; `FORBIDDEN` requires the appropriate workspace role. `FORBIDDEN_ORIGIN` rejects a cross-origin mutation. `PLAN_REQUIRED` indicates unavailable feature access or allowance. For `CREDENTIALS_REJECTED`, review provider credentials. For `CREDENTIALS_UNREADABLE` or `ENCRYPTION_NOT_CONFIGURED`, the deployment operator must check credential encryption. Never solve these by putting secrets in the client. See [Metadata](/docs/metadata#conflicts) for stale and partial publication, [Price localization](/docs/price-localization#results) for uncertain writes, and [Troubleshooting](/docs/troubleshooting) for symptom-based checks. ## Report a failure {#support} Record the route, HTTP status, error code, SDK version and correlation ID when available. Public responses can include `correlationId`; the current Swift `server` case exposes status, code and fields, not a correlation-ID property. Capture safe response metadata through your transport if you need that identifier. Do not log identity signatures, API keys, private report bodies or screenshots. --- # Limits These are current validation and transport limits, not a promise of workspace quota. The server remains authoritative even when a native control accepts a value. ## Input and payload limits {#inputs} | Input | Server limit | | --------------------------------- | ---------------------------------------------------------------------------------- | | Public App ID | 3–100 characters | | Idea title | 4–140 characters | | Idea description | Up to 4,000 characters | | Comment | 2–2,000 characters | | Author name | 1–80 characters when supplied | | Community search | Up to 120 characters | | Idea/comment page size | Default 20; 1–50 | | Chat message | 1–2,000 characters | | Private feedback message | 3–5,000 characters | | Feedback page URL / title / email | Up to 2,048 / 300 / 320 characters | | Diagnostics | Up to 100 entries; 1,000 characters per message | | Diagnostic metadata | Up to 20 keys and 8,000 JavaScript string units after JSON serialization per entry | | Screenshot | PNG, JPEG or WebP base64 data URL; up to 2,800,000 characters | | Entire feedback request | 3,500,000 bytes | | Community JSON body | 32,000 bytes | | Idempotency key | 8–128 characters from letters, digits, `.`, `_`, `:` and `-` | | Signed identity credential | Maximum lifetime of 24 hours | Screenshots count toward the complete request size. Native screenshot validation checks the data URL's UTF-8 length. Diagnostic sanitization is additional protection, not a substitute for collecting only what you need. ## Native input constants {#swift} `AppNorthInputLimits` currently exposes `ideaTitle = 100`, `ideaDescription = 5_000` and `comment = 2_000`. These do **not** exactly match server idea validation: a 5,000-character native description can be rejected by the server's 4,000-character maximum. For custom UI, validate against the server limits above and handle field errors. Do not infer server limits solely from these constants. ## Rate limits {#requests} Private-feedback creation allows 20 requests per minute per project/IP bucket. Private thread reads allow 60. Chat message creation allows 20, while chat status and message reads allow 60 per action bucket. Public community routes also share an IP-level budget of 1,200 requests per minute before project resolution. The identity handoff additionally has an IP limit of 30 requests per minute and a project/action limit of 20. These budgets can overlap: remaining capacity in one does not bypass another. On HTTP 429, honor `Retry-After` when returned. Avoid polling from multiple views at once and retain a mutation's idempotency key across retries. ## Workspace allowances {#workspace} App counts, tracked keywords, AI drafts, market access and watchlists are governed by workspace entitlements. Check the current billing and usage screens rather than treating the transport limits as commercial allowances. Provider APIs may impose additional limits. --- # Privacy This page describes the SDK's current manifest and data paths. Your app determines which optional data it sends and whether that data is linked to a signed-in user. ## Bundled privacy declarations {#manifest} `PrivacyInfo.xcprivacy` declares tracking as false and lists no tracking domains. It declares access to UserDefaults with reason `CA92.1`; the default anonymous-token provider uses UserDefaults to persist a random voter token. The manifest declares these data categories for app functionality, with linked-to-user and tracking flags set to false: | Manifest category | Relevant SDK data | | ------------------- | ------------------------------------------------- | | User ID | Anonymous voter token used for participation | | Product interaction | Participation such as votes and community actions | | Other user content | Ideas, comments and feedback content | These are the SDK manifest's declarations, not a completed privacy label for every host app. Verified identity, emails, custom properties, screenshots and diagnostics can change what your integration collects and links to a person. Review those actual data flows when preparing your app's disclosures. The source package excludes the manifest from its source target; the XCFramework build process copies it into the framework resources. Inspect the artifact you distribute rather than assuming a source checkout proves the packaged manifest is present. ## Public and private content {#visibility} Community ideas and comments are intended for public display under the configured moderation rules. Private feedback, chat, device context and diagnostics belong to the support workflow. Internal notes are excluded from user-facing private threads. Only promote a private report into a public idea after reviewing the public title and description. Do not copy private contact information, screenshots or diagnostic logs into it. ## Identity and storage {#identity} Verified credentials come from your authenticated backend. The signing secret stays there; the client receives a short-lived credential and holds it in memory. Clear that credential on sign-out and separate caches and queued writes across account changes. The default anonymous token is persistent rather than a fresh value for every request. Custom token providers, read caches and offline queue storage can change persistence behavior; review the implementations your host app supplies. Server-side tenant and reporter checks restrict private data access. A public App ID identifies the destination app; it is not proof of a user's identity. ## Screenshots and diagnostics {#screenshots} `captureAppNorthScreenshot` requires consent, a capture adapter and an app-specific redactor. It returns no screenshot if capture or redaction fails, the output is invalid, or redaction returns the unchanged image. The SDK does not infer which parts of your UI are sensitive. Diagnostics are optional structured entries. The server sanitizes recognized sensitive patterns, but cannot guarantee that arbitrary text or images contain no personal data. Redact before submission and keep screenshots and logs out of routine error telemetry. For public review drafts, the configured AI provider receives the app name and review context when drafting is requested. Store-connection secrets are sealed on the server. See [Verified identity](/docs/verified-identity), [Private feedback](/docs/private-feedback) and [Offline and cache](/docs/offline-and-cache) for implementation details. --- # Troubleshooting ## Check first {#check-first} 1. Confirm you are using the intended live workspace and app, not demo data. 2. Copy the **public App ID** from the installation page. Dashboard project IDs and Apple app IDs are different identifiers. 3. Use the API base URL, without appending `/api/v1`; the Swift client adds its endpoint paths. 4. Check the configured origin, allowed domain, SDK version and network access. 5. Record the failing route, status, stable error code and correlation ID if available. Remove secrets and user content before sharing logs. ## The client fails to initialize {#configuration} `AppNorthConfiguration` requires an absolute HTTP(S) API URL with no embedded credentials, a 3–100-character App ID, and an origin containing only scheme, host and optional port. A page path, query or fragment does not belong in the origin. ## Feedback is rejected by origin checks {#origin} Check the origin passed to the client against the app's allowed domains. Keep feedback ingestion's origin requirement in place. Some native chat endpoints allow a missing Origin, but that exception does not remove feedback's checks. See [Errors](/docs/errors) for `ORIGIN_NOT_ALLOWED`. ## Sign-in works, but participation does not {#identity} Your app login and AppNorth's signed identity are separate. Fetch a credential from your backend and pass it to the same client used by the view. Check its expiry, app-specific signing inputs and identity configuration. Renew an expired credential instead of falling back to anonymous access to a private thread. On account changes, clear the old credential and avoid sharing cached private content or pending writes between accounts. For a hosted roadmap, use the short-lived [identity handoff](/docs/roadmap-and-changelog#handoff); an app credential is not automatically a browser session. ## Chat is hidden or cannot send {#chat} Check the app's chat mode: disabled, identified or anyone. Identified mode needs valid identity; anyone mode still needs the anonymous token used for that conversation. If the floating chat control is hidden by native UI configuration, enable it or present `AppNorthChatView` directly. Check the message limit and network error. For custom chat models, start polling while the conversation is active and stop it when leaving. A saved team reply and an email delivery state are separate things. ## A screenshot or description disappears {#payloads} Screenshot capture fails closed unless consent, capture and redaction all succeed. An unchanged redaction result is rejected. Check data URL format and the screenshot/body limits. For rejected idea descriptions, note the current mismatch between native constants and the server maximum in [Limits](/docs/limits#swift). Use the server's field errors to correct the input. ## Store data is missing or stale {#store} Verify that the app is imported and its App Store Connect connection is active. An app-list credential check does not guarantee permission for every operation. Reviews and keywords need successful collection jobs before new observations appear. RevenueCat charts need a valid secret v2 key, overview permission and a successful snapshot. Historical snapshots begin with collection; missing days are not fabricated. Explore also depends on collected market records and imported media. ## Publishing reports a conflict or uncertainty {#publishing} Reload metadata after `STALE` or `PARTIALLY_PUBLISHED`, then compare Apple's current values before retrying. For prices, inspect each job item; cancellation cannot undo completed writes, and an uncertain write may already have reached Apple. See [Metadata](/docs/metadata#conflicts) and [Price localization](/docs/price-localization#results) for the recovery paths. When retrying an SDK submission, reuse its original idempotency key and unchanged payload. --- # Versions The installation snippets currently advertise SDK version `0.2.0` from `https://github.com/victorgxn/appnorth-ios`. This is the repository's configured installation version; it is not an assertion that every source change is already present in a published binary. ## Supported integration {#requirements} The native product is the `AppNorth` Swift library for **iOS 16 and later**. The source package declares Swift tools version 5.9. Apps install the compiled XCFramework through Swift Package Manager; use [Install](/docs/install) for the current dependency snippet. Native on-device translation is conditionally available on iOS 18 and later. Basic community and chat integration does not require that optional translation feature. The hosted dashboard, roadmap and changelog are browser surfaces for the iOS product. The repository's browser support policy targets the two latest stable Chrome, Edge, Firefox and Safari versions; local tests do not establish physical-device coverage. ## API and persisted formats {#compatibility} The SDK talks to the `/api/v1` contract. SDK package version, API contract version and persisted cache/queue format version are separate values. A package update does not imply that persisted data can be discarded or that a server endpoint changed. Optional response fields can be added without requiring every client to read them. Changes to required fields, routes or established semantics need an explicit compatibility and migration review. ## Update an integration {#upgrade} 1. Read the release notes for the exact package version you plan to install. 2. Update the Swift Package dependency and resolve it in Xcode. 3. Build the host app against that binary; do not rely only on examples compiled against the source checkout. 4. Exercise anonymous participation, verified identity, account changes, private feedback, chat and a recoverable network failure. 5. Check idempotent retries and any custom cache or queue storage before rolling out. Keep the previous dependency resolution available for rollback. If a persisted format changes, preserve pending submissions and their idempotency keys or provide a visible recovery path. Do not silently erase queued user content. ## Release verification {#release} The repository's release checks include SDK tests, shared contract checks, the native example, a secret scan and XCFramework packaging. Publication, compatibility against a deployed server and physical-device validation require separate evidence. The [Swift API reference](/docs/swift-api) describes the checked-in source surface. Confirm availability in your selected release before adopting a symbol that was recently added. No historical release changelog is inferred from the current source tree.