> For the complete AppRefer docs index, see https://apprefer.com/llms.txt (every page as markdown) or https://apprefer.com/llms-full.txt (all docs in one file).

# Customer support

In-app chat, a website chat, email, a help centre and optional AI answers, all answered from one Support inbox in the dashboard.

Customers write from your app or by email; your team answers from the Support inbox. Each conversation shows what the customer bought and, for your own team, which campaign brought them in.

## Set up with your coding agent

Every app has a tailored setup prompt under **Settings → Setup guide**. Pick the framework (iOS, Android, Flutter or React Native) and whether the app has its own backend, then copy one prompt into the coding agent working in the app's repository. It is built from the app's current AppRefer setup and covers the SDK, the configure / sign-in / sign-out order, the chat and help centre, verified identity, live customer context, resolution actions, offer cards opening your paywall (with the store's own billing periods), review cards and honest analytics.

-   **Company owners and admins only.** Members, viewers and Support seats can't open it; another company's app answers not found.
-   **No secrets.** It holds public `pk_` SDK keys (the ones the settings page already shows you) and placeholders for everything else. It tells the agent to keep the organisation key in the backend's secret store with only `support:context` and `support:actions`, and never to log identity tokens.
-   **Names are data.** App, persona, offering and action names appear only in one escaped JSON block the agent is told to treat as data, so text typed into the dashboard can't steer it.
-   **Nothing is switched on.** The agent is told not to change AI, Billing, ad network, engagement or delivery settings. Whatever AppRefer can't see working yet (push keys, email, help articles, Billing, store pages, a backend that polls for actions) comes back as a list of remaining steps with who has to do each.
-   **AppRefer Billing only.** When Billing runs your purchases (primary mode), the agent moves purchases, restores and entitlement checks off any other purchase or paywall SDK and removes it. In shadow mode it leaves that SDK in place, and switching Billing to primary is listed as a remaining step.
-   **SDK 0.12.0 isn't released yet.** Until it is, the prompt makes the agent check the release tag first and never install an untagged SDK.

The prompt is read-only to produce: it calls no store or push provider and changes nothing. Dashboard endpoint: `GET /api/dashboard/setup-prompt?appId=&framework=ios|android|flutter|react-native&backend=yes|none` (signed-in session).

## Neutral header (SDK 0.11.1)

Set `headerStyle` before opening Support to use a flat neutral grey home header with white or near-black text and no glow. The default is `accent`. From SDK 0.11.2 the avatar on the Continue card is neutral grey too; an AI assistant's portrait still shows. Bubbles and buttons keep your accent colour.

**Swift**

```swift
AppRefer.support.headerStyle = .plain
```

**Kotlin**

```kotlin
AppRefer.support.headerStyle = AppReferSupportHeaderStyle.PLAIN
```

**Dart**

```dart
AppReferSDK.support.headerStyle = AppReferSupportHeaderStyle.plain;
```

**TypeScript**

```typescript
AppRefer.support.headerStyle = 'plain';
```

## Add the chat to your app

The SDKs include a ready-made help chat in your app's accent colour: a home screen with your team's names and usual reply time, the conversation to pick up again, quick-help tiles and a message bar that takes screenshots, then conversations with one-tap quick replies. Light and dark mode. One line opens it:

**Swift**

```swift
AppRefer.support.open()
AppRefer.support.identify(name: "María", email: "maria@example.com")

// Optional look and copy
AppRefer.support.teamNames = ["Sam", "Ana"]
AppRefer.support.replyTime = "under an hour"
AppRefer.support.topics = [.billing, .bug, .idea]   // quick-help tiles; [] hides them
AppRefer.support.compose("Problem with my subscription") // opens with the message filled in

// Reply notifications
AppRefer.support.registerDeviceToken(deviceToken)   // from didRegisterForRemoteNotifications
AppRefer.support.handleNotification(userInfo)       // on tap; true = it was a support reply
```

**Kotlin**

```kotlin
AppRefer.support.open(context)
AppRefer.support.identify(name = "María", email = "maria@example.com")

AppRefer.support.teamNames = listOf("Sam", "Ana")
AppRefer.support.replyTime = "under an hour"
AppRefer.support.compose(context, "Problem with my subscription")

// Reply notifications (FCM)
scope.launch { AppRefer.support.registerPushToken(fcmToken) }
AppRefer.support.handleNotification(remoteMessage.data)  // true = it was a support reply
```

**Dart**

```dart
await AppReferSDK.support.open(context: context);
await AppReferSDK.support.identify(name: 'María', email: 'maria@example.com');

AppReferSDK.support.teamNames = ['Sam', 'Ana'];
AppReferSDK.support.replyTime = 'under an hour';
await AppReferSDK.support.compose('Problem with my subscription', context: context);

// Reply notifications: APNs token (hex) on iOS, FCM token on Android
await AppReferSDK.support.registerPushToken(token);
AppReferSDK.support.handleNotification(message.data, context: context);
```

**TypeScript**

```tsx
// Mount the host once, next to your navigation
<><AppNavigation /><AppReferSupportHost /></>

AppRefer.support.open();
await AppRefer.support.identify('María', 'maria@example.com');

AppRefer.support.teamNames = ['Sam', 'Ana'];
AppRefer.support.replyTime = 'under an hour';
AppRefer.support.compose('Problem with my subscription');

// Reply notifications: APNs token on iOS, FCM token on Android
await AppRefer.support.registerPushToken(token);
AppRefer.support.handleNotification(remoteMessage.data ?? {});
```

A team member's replies show their first name only, never their email address. The chat checks for replies only while it's open; otherwise replies arrive by push, and by email if they stay unread for an hour. `unreadCount()` returns the number of unread team replies, for a badge.

Messages sent from the chat carry a `clientMessageId` that stays the same on every retry, so a message sent twice on a flaky connection is stored once.

## Add the chat to your website

One script tag puts the same chat on your website: a button in the corner opens it, visitors can search your help articles, write to you and add photos, and your replies (or your AI assistant's) appear while the page is open. Conversations land in the same Support inbox, marked Web, with the page they wrote from.

```html
<script src="https://apprefer.com/widget/v1.js" data-key="pk_live_..." async></script>
```

Use the app's public `pk_live_` key (a `pk_test_` key keeps conversations in the inbox's Test view). Optional attributes: `data-accent="#7856FF"`, `data-theme="auto|light|dark"`, `data-position="right|left"`, `data-language="de"` and `data-launcher="false"` to open it from your own button. The chat speaks the same 35 languages as the in-app chat, following the page's `<html lang>` and then the visitor's browser, and right-to-left languages are laid out right to left.

```javascript
// Your own "Help" button
AppRefer.support.open();
AppRefer.support.compose("I need help with my order");

// Signed-in visitors: name and email show in the inbox; your user id
// keeps their conversations together across devices.
AppRefer.support.identify({ name: "Sam", email: "sam@example.com", userId: "u_123" });
// Optional: prove the user id with a token from your backend (see Verified identity).
AppRefer.support.verifyIdentity(identityToken);

// Sign-out: the next visitor on this browser never sees these conversations.
AppRefer.support.reset();

AppRefer.support.on("unread", (count) => updateBadge(count));
AppRefer.support.setLauncher(false); // hide the button on some pages
AppRefer.support.setTheme("light");
```

Calls made before the script loads can wait in a queue: `window.AppRefer = { q: [["identify", { email: "sam@example.com" }]] }`. The visitor's session lives in the browser's local storage. When the app has email replies set up (Support settings), the chat offers to email answers after the visitor's first message, so someone who leaves the page still gets the reply and can answer it by email.

## Accounts: sign-in, sign-out and Support-only apps

Support follows the user id you give AppRefer (`setUserId`), so conversations line up with purchases and attribution.

-   **Account switch.** A support session belongs to the first signed-in user it saw. If another user id arrives on the same device, AppRefer starts a new session, so the next account never sees the earlier account's conversations.
-   **Sign-out.** `AppRefer.reset()` (SDK 0.9.0+) revokes the Support session on the server, and clears the stored name, email, conversation history and push registration on the device. A revocation that can't reach AppRefer is retried on the next launch, foreground or configure. After `reset()`, Support sends no device id until `configure` runs again (SDK 0.9.1+).
-   **Remove the account from Support only** (for example when the user withdraws consent): `AppRefer.support.clearIdentity()` (SDK 0.9.2+) forgets the name, email and user id, revokes the session and starts a new anonymous one with no user id or device id. Old conversations stay in your inbox but no longer show in the app. The push token moves to the new session only after the old one is revoked. Attribution and Billing are untouched.
-   **Support without attribution.** `AppRefer.support.configure(apiKey)` (SDK 0.9.0+; Android `configure(context, apiKey)`, Flutter `configure(apiKey: ...)`) starts Support alone, with no device signals or product analytics.

## Help centre and languages

The built-in chat speaks 35 languages, follows the app language and supports right-to-left layouts. Set the support client's `language` to override it and `strings` to replace individual phrases. Published public articles appear automatically on the chat home (SDK 0.10.0+); apps without articles show no help section.

**Swift**

```swift
let articles = try await AppRefer.support.articles(query: "restore")
let article = try await AppRefer.support.article(slug: "restore-purchases")
AppRefer.support.openHelpCentre()
AppRefer.support.open(article: "restore-purchases")

let status = try await AppRefer.support.status()
if status.pushReady {
    // Request notification permission and register your APNs token.
}
```

**Kotlin**

```kotlin
val articles = AppRefer.support.articles("restore")
val article = AppRefer.support.article("restore-purchases")
AppRefer.support.openHelpCentre()
AppRefer.support.openArticle("restore-purchases")

if (AppRefer.support.status().pushReady) {
    // Request POST_NOTIFICATIONS and register your FCM token.
}
```

**Dart**

```dart
final articles = await AppReferSDK.support.articles(query: 'restore');
final article = await AppReferSDK.support.article('restore-purchases');
await AppReferSDK.support.openHelpCentre(context: context);
await AppReferSDK.support.openArticle('restore-purchases', context: context);

if ((await AppReferSDK.support.status()).pushReady) {
  // Request permission and register your token.
}
```

**TypeScript**

```typescript
const articles = await AppRefer.support.articles('restore');
const article = await AppRefer.support.article('restore-purchases');
AppRefer.support.openHelpCentre();            // with <AppReferSupportHost /> mounted
AppRefer.support.openArticle('restore-purchases');

if ((await AppRefer.support.status()).pushReady) {
  // Request permission and register your token.
}
```

`status()` returns `pushReady`, `emailReplies` and `helpCentreArticles`. Check it before registering for reply notifications. Articles need only the SDK key, use the chat's language, are cached in memory for five minutes and stay readable from cache offline. A missing article is a not-found error. The reader shows markdown and opens only HTTP, HTTPS and mailto links.

## How customers are identified

The app calls the session endpoint with the public SDK key. AppRefer returns a support session token (`ars_`), which the SDK keeps in secure storage; only its hash is stored on the server. A customer only ever sees conversations started with their own session, in the same app. Sessions made with a test key (`pk_test_`) are separate from live ones and their conversations appear only under the inbox's Test toggle. A session unused for 400 days expires.

## Live customer context

Next to each conversation the inbox shows what is true about the customer right now, and the AI agent gets the same facts as data. It comes from three places, each with its own state (ok, empty, stale, unavailable, not configured, withheld), so one missing part never hides the others:

-   **Identity.** The app user id the app reported, and whether your backend vouched for it.
-   **Plan.** AppRefer Billing entitlements, the current plan and the latest subscription changes (renewal turned off, billing issue, refund). Store transaction ids, account tokens and prices are never shown.
-   **From the app.** Records your own backend sends: credits, orders, jobs, recent failures and simple facts, each with the time it last changed.

Your backend sends records with a private organisation key holding `support:context` (standard `*` keys include it). Each call replaces one source's snapshot for one customer. AppRefer never fetches a URL or runs your code for it, and the payload has no free-form fields: unknown fields are refused.

```http
PUT /api/v1/apps/{appId}/support/customers/{appUserId}/context
Authorization: Bearer ak_live_…
Content-Type: application/json

{
  "source": "orders",
  "observedAt": "2026-10-19T11:59:00Z",
  "staleAfterMinutes": 60,
  "records": [
    { "type": "order", "id": "ord_1001", "at": "2026-10-19T11:00:00Z", "status": "in_production",
      "total": { "amount": 29.5, "currency": "USD" }, "itemCount": 2, "label": "Hoodie, navy" },
    { "type": "credits", "id": "credits", "at": "2026-10-19T10:00:00Z", "balance": 12, "unit": "credits" },
    { "type": "failure", "id": "f_88", "at": "2026-10-19T09:00:00Z", "code": "render_timeout", "severity": "error", "label": "Design render timed out" }
  ]
}
```

| Record type | Fields |
| --- | --- |
| credits | balance (number), unit?, expiresAt? |
| order | status (pending, paid, processing, in\_production, shipped, delivered, on\_hold, cancelled, refunded, failed), total? { amount, currency }, itemCount? |
| job | status (queued, running, succeeded, failed, cancelled), code? |
| failure | code, severity? (info, warning, error) |
| fact | value (text, number or true/false); label required |

Every record has `type`, `id` (up to 64 of A-Z, a-z, 0-9, `_ . : -`), `at` (ISO 8601 with a zone) and an optional `label` (up to 120 characters). Labels, units and text values are display text: they come back under `labels`, apart from the checked fields, and the AI is told to read them as data, never as instructions. Limits: 50 records (20 per type), 5 sources per customer, 32 KB per call. `environment` is `live` (default) or `test` (shown for test-key conversations). An `observedAt` older than the stored one is refused with `409 STALE_SNAPSHOT`; the same snapshot again returns `changed: false`. A snapshot older than `staleAfterMinutes` (default 1440) is marked as possibly out of date. Context nobody refreshes for 90 days is deleted, and erasing a user deletes it at once. `GET` on the same path returns what is stored; `DELETE` removes one `?source=` or all.

### Verified identity

The app user id on a support session comes from the customer's device, so anyone holding your public SDK key could claim one. Until your backend vouches for it, the inbox marks the customer **Not verified** (your team still sees the claimed account's plan and records, with a warning) and the AI agent receives no account data at all: no plan, purchases or app records. Your backend asks for a single-use identity token after signing the user in, and the app passes it to the SDK (0.12.0+):

**Swift**

```swift
let check = try await AppRefer.support.verifyIdentity(tokenFromYourBackend)
// check.verified, check.reason ("expired", "already_used", "account_changed"…)
```

**Kotlin**

```kotlin
val check = AppRefer.support.verifyIdentity(tokenFromYourBackend) // suspend
```

**Flutter**

```dart
final check = await AppReferSDK.support.verifyIdentity(tokenFromYourBackend);
```

**React Native**

```typescript
const check = await AppRefer.support.verifyIdentity(tokenFromYourBackend);
```

```http
POST /api/v1/apps/{appId}/support/identity-tokens      (your backend, private key)
Authorization: Bearer ak_live_…
{ "appUserId": "user_42", "environment": "live", "ttlSeconds": 300 }
→ { "data": { "identityToken": "sit1.…", "expiresAt": "…" } }

POST /api/support/v1/session/identity                     (the SDK, on the current session)
X-AppRefer-Key: pk_live_…
X-AppRefer-Support-Session: ars_…
{ "identityToken": "sit1.…" }
→ { "identity": { "verified": true, "appUserId": "user_42", "verifiedAt": "…" } }
```

A token is bound to one app, environment and user, lasts 30 to 600 seconds (default 300) and verifies one session. It verifies the session the customer already has, so conversations already started on it count too. The token is used in the same write that marks the session verified, so a failed write never burns it, and the same session sending the same token again (a retry after a lost answer) gets the same success; any other session gets `already_used`. A refusal is `200` with `identity.verified: false` and a `reason`: `malformed`, `bad_signature`, `other_app`, `other_environment`, `expired`, `bad_times`, `already_used`, `user_mismatch` (the session belongs to another user; the token is not used), `session_replaced` or `session_gone`. `503` means nothing was used: retry the same token. A session with no user yet is claimed for the token's user; conversations it started anonymously stay unidentified.

Verification is checked live every time: it lasts while the conversation's own session still exists for the same app, environment and user and was verified within the last 30 days. Signing out (`reset()` revokes the session), another account signing in on the device (its old session is replaced) or erasing the user ends it; nothing stored on a conversation counts on its own. The SDKs retry network failures with the same token and answer `account_changed` when the account changed while it ran.

Plan and purchases are kept to the conversation's environment: a live conversation sees production purchases and promotional grants, a test-key conversation sees sandbox purchases only.

## Resolution actions

Your backend can register a few named things it is able to do for one of its customers (look up an order, retry a failed job, add missing credits), and your team runs them from the conversation without leaving the inbox. AppRefer never calls a URL, runs your code or holds your secrets: your backend **pulls** the work with a private organisation key holding `support:actions` (standard `*` keys include it), does it, and reports the result. Until it does, the run shows as waiting; a run nobody confirms ends as **no result**, never as done. Apps that register nothing show “not connected” in the inbox.

```http
PUT /api/v1/apps/{appId}/support/actions            (replaced whole, at most 20)
Authorization: Bearer ak_live_…
{
  "actions": [
    { "id": "order_status", "label": "Check order status", "effect": "read", "ai": "perform",
      "params": [{ "name": "order", "label": "Order", "type": "record", "recordType": "order" }] },
    { "id": "retry_job", "label": "Retry failed design", "effect": "change", "ai": "propose",
      "params": [{ "name": "job", "label": "Job", "type": "record", "recordType": "job" }] },
    { "id": "add_credits", "label": "Add missing credits", "effect": "change", "staff": "lead",
      "params": [{ "name": "amount", "label": "Credits", "type": "integer", "min": 1, "max": 500 }] }
  ]
}
```

| Field | Meaning |
| --- | --- |
| id | a-z, 0-9 and \_ (up to 40), starting with a letter |
| label, description? | What the team sees (60 / 300 characters). Shown as plain text. |
| effect | read (looks something up) or change (does something in your app) |
| params? | Up to 6: string (maxLength ≤ 200), integer or number (min, max), boolean, enum (up to 20 options) or record (the id of one of the customer's live-context records of recordType, so a run can only point at their own order or job) |
| staff? | reply (default: anyone who may reply), lead (support leads and company admins) or none |
| ai? | none (default), propose (the AI suggests it to the team) or perform (the AI may run it, in auto mode only) |
| refundUsdParam? | A required number parameter holding a refund amount in USD: runs are held to the person's Support refund limit and the AI can only propose it. Your app moves the money; AppRefer doesn't. |
| environments? | live and/or test (default both) |
| timeoutSeconds? | 30 to 3600 (default 300): after this an unconfirmed run ends as no result |
| identityMaxAgeMinutes? | 5 to 1440 (default 1440): how recent the customer's verification must be |
| enabled? | false hides it without removing it |

A run needs the conversation's customer [verified now](#verified-identity), by default within the last 24 hours (apps that call `verifyIdentity` on every start and sign-in stay well inside that). It is bound to one company, app, environment, conversation, support session and user, and everything is checked again each time your backend claims it: the action unchanged and enabled, the session still verified for the same user, and whoever asked still allowed to (their Support access and refund limit; for the AI, the app's AI still in auto mode). A sign-out, another account signing in or erasing the user before your backend picks it up cancels the run (nothing ran). Erasing a user deletes their runs.

```http
POST /api/v1/apps/{appId}/support/actions/claim      { "environment": "live", "limit": 5 }
→ { "data": { "executions": [{
    "executionId": "sar_…", "leaseId": "…", "leaseUntil": "…", "attempt": 1, "redelivery": false,
    "action": { "id": "order_status", "version": "…" }, "environment": "live",
    "appUserId": "user_42", "params": { "order": "ord_1001" },
    "requestedBy": "staff", "requestedAt": "…", "deadlineAt": "…" }] } }

POST /api/v1/apps/{appId}/support/actions/runs/{executionId}/complete
{ "leaseId": "…", "status": "succeeded", "code": "on_hold",
  "message": "Held at the printer: address check", "data": { "carrier": "DHL", "days_late": 2 } }

GET /api/v1/apps/{appId}/support/actions/runs/{executionId}     (status)
```

Poll `claim` every few seconds per environment (each call also tells the inbox your backend is connected). A claim lasts two minutes. If your result doesn't arrive, the same execution is handed over again with the **same** `executionId` and `redelivery: true`, so **apply each executionId at most once** and send the stored result again. A result is accepted under any lease given for that execution; the same result twice is fine, a different one is `409 ALREADY_COMPLETED`. `data` takes up to 10 fields (number, true/false or text up to 120 characters), all shown to the team as plain text. A result that arrives after the deadline is still recorded, marked late. The claim carries no conversation text or staff names. Runs are kept 90 days.

The AI agent sees the actions the app lets it use (and this conversation's runs with their results) only while the customer is verified, and asks for one with `action: { id, params }` in its result. AppRefer checks the id and parameters against your definition: anything else is refused. A run the AI starts holds its answer; when your backend reports, the AI gets another pass and can answer from the result, citing the run as its source. In drafts mode, or when the conversation needs a person, an action is only proposed to the team.

## Offer cards

Your team (or the AI assistant) can attach one of your app's [AppRefer Billing offerings](https://apprefer.com/docs/offerings.md) to a reply. The chat shows it as an "Options for you" card with the store's own prices and a **See options** button that opens **your app's own paywall** through a callback you register. The wording stays neutral because a card can hold ordinary full-price plans, credit packs or one-off purchases: each price carries the store's own billing period ("£29.99/year", never a free-trial or introductory price), an owned subscription is tagged "Current plan" and an owned one-off "Purchased". AppRefer never builds a paywall, never starts a purchase and never shows a price, discount or deadline of its own: prices come from the App Store or Google Play on the device, and a purchase counts only when AppRefer Billing confirms it from the store, as usual. Opening the paywall is recorded as a tap, never as a purchase.

**Swift**

```swift
AppRefer.support.onOffer = { offer in
    // Open your own paywall for this offering (packageId may be preselected).
    Paywall.present(offeringId: offer.offeringId, packageId: offer.packageId)
}
```

**Kotlin**

```kotlin
AppReferSupport.onOffer = { offer ->
    PaywallActivity.start(context, offer.offeringId, offer.packageId)
}
```

**Flutter**

```dart
AppReferSDK.support.onOffer = (offer) =>
    navigatorKey.currentState?.push(PaywallRoute(offer.offeringId, offer.packageId));
```

**React Native**

```typescript
AppRefer.support.onOffer = offer =>
  navigation.navigate('Paywall', { offeringId: offer.offeringId, packageId: offer.packageId });
```

By default the chat closes first and then your callback runs (`closesChatOnOffer = false` keeps it open, e.g. when you embed the chat view). Without a callback the card asks the customer to update the app to open it; apps on SDKs older than 0.12.0 see your text plus a line asking them to update, never a dead card. Each request tells AppRefer which of these applies, so the inbox and the AI know whether a card will open a paywall for this customer.

**What can be sent.** Only a live (not archived) offering of the conversation's app, optionally one of its packages, with optional text up to 1,000 characters, in in-app conversations, at most 20 cards per conversation. The card is bound to the company, app, environment, support session and account it was sent to. Offerings every customer can already reach (the current offering, a placement, a targeting rule or a running experiment) can be sent to anyone; any other offering needs the customer verified (see Verified identity). The AI may attach a card only for a verified customer, and only to an answer with words; anything else is refused and its answer waits for a person.

**State.** Every read returns the card as it is now: `available`, `owned` (every package it points at is the customer's current subscription or an owned one-time purchase, checked only for a verified customer) or `unavailable` with a reason: `revoked` (withdrawn in the inbox), `expired` (cards can be opened for 30 days), `offering_unavailable`, `not_on_platform` or `account_changed`. Switching plans is never blocked: the current plan is just tagged. Google Play subscriptions are never marked owned, because Play records the subscription without its base plan. The chat refreshes cards when it opens, every 30 seconds while open, after a purchase, restore or sign-in reported by AppRefer Billing, and when the app comes back to the foreground.

```json
"offer": {
  "cardId": "ofc_3f…", "offeringId": "main", "packageId": null,
  "status": "available", "reason": null, "eligibility": "checked",
  "packages": [
    { "id": "$ar_annual", "packageType": "annual", "displayName": null, "productId": "pro.annual", "current": false },
    { "id": "$ar_monthly", "packageType": "monthly", "displayName": null, "productId": "pro.monthly", "current": true }
  ]
}
```

**Receipts.** The SDK reports `shown` once per card and `opened` on each tap, each with its own `clientEventId`; receipts made offline are kept on the device (up to 7 days) and sent again with the same id, so each is stored once. Before opening your paywall the SDK asks AppRefer for up to 3 seconds whether the card can still be opened; a withdrawn or changed card redraws instead, and no answer opens the paywall anyway (the store decides). Card records and receipts keep the app user id for outcome reporting and are removed by user erasure; they expire after 400 days.

## Review cards

Your team can attach an optional **Write a review** card to a reply (＋ Review link in the inbox). The chat shows it as "Share your experience" with one button that opens your app's own store page: the App Store write-review page on iOS (`https://apps.apple.com/app/id<id>?action=write-review`) and the Google Play listing on Android. Both stores recommend exactly this for a button the customer taps; their in-app review prompts are limited by the store and can silently show nothing, so the SDKs never put them behind a button.

**No setup for most apps.** AppRefer builds the link from what your app already has: the App Store id from AppRefer Billing or the App Store URL in app settings, and the package name from Billing or the Google Play URL. If two of these name different apps, review cards are off for that store until you fix them, rather than guessing. Only an app with neither can set the bare id in `supportReviews` (`PATCH /api/v1/apps/{appId}` or MCP `update_app`): `{ enabled?: true, frequencyDays?: 30-365 (120), appStoreId?, playPackageName? }`. Identifiers only, never URLs. The SDKs check the link again and open nothing else.

**Neutral and optional.** No stars, no "do you like the app?" question first, no reward, nothing that depends on the answer, and never sent automatically. The AI assistant can only suggest a card in a draft for a person to send. AppRefer sends at most one card per customer (account, else device session) per app and environment every `frequencyDays`; withdrawing a card that was never opened frees the slot again.

**State.** A card is `available` (with the store and URL for the device's platform) or `unavailable`: `revoked`, `expired` (30 days), `account_changed`, `disabled` (review cards turned off), `store_changed` (the store page changed after it was sent) or `not_on_platform`. Taps are recorded as `opened` receipts; neither seen nor opened is a review or a rating, and the chat never says a review was written. SDKs older than 0.12.0 (no `reviews` in `X-AppRefer-Support-Features`) get your text plus a line asking them to update the app.

## Photo and video testimonials

Ask a signed-in customer for a photo or a short video and give them the reward you choose, from your own screens, from your coach or assistant, from a Support reply, or automatically after real usage. One native capture screen in all four SDKs (0.13.0) shows what you ask, the reward, the system photo picker or camera, a preview, your exact usage terms above a plain Submit, upload progress and the result.

**Campaigns.** Your backend registers up to 10 with a private key holding `support:testimonials`: `PUT /api/v1/apps/{appId}/support/testimonials/campaigns` `{ campaigns: [{ id, label, title, prompt, media: [image|video], maxVideoSeconds? 5-120 (30), reward: { type, quantity?, description }, permission: { text, scopes }, environments?, frequencyDays? 1-730 (90), requestTtlDays? 1-30 (14), ai?: none|propose|send, automation?: { event, minCount, withinDays }, enabled? }] }`. Reward types are `credits`, `coach_usage`, `subscription_time` (each needs a whole-number `quantity`), `discount`, `gift`, `merch`, `manual` or `none`. Scopes are `in_app`, `website`, `social`, `ads`, `store_listing` and `email`. A registered campaign does nothing until a company admin switches it on in Support (Testimonials); automatic asks have their own switch.

**Asking.** A request is only ever made by something you trust: your backend or its coach tool (`POST /api/v1/apps/{appId}/support/testimonials/requests` `{ campaignId, appUserId, environment?, source?: host|coach, idempotencyKey }`), a person in the inbox (＋ Photo/video), the AI within your Support AI policy (campaigns with `ai: propose` wait for a person; `send` may go out with an instant answer in auto mode), or automation. The reward always comes from the campaign; the request call has no reward field. Each customer has at most one open request per campaign (another entry point gets the same one back), at most one ask per `frequencyDays`, and one reward per campaign. A customer who answers "Don't ask me again" is never asked again for that app.

**Answering.** The SDK key alone can only answer a request for the account its Support session is verified for now (`verifyIdentity`). Another account's request reads as missing; the same account unverified gets `IDENTITY_REQUIRED`. Uploads go straight to storage with a 15-minute signed PUT of an exact type and size (photos JPEG, PNG, HEIC or WebP up to 15 MB; videos MP4 or MOV up to 150 MB). On submit AppRefer checks the stored bytes, not what the app said: the file type from its first bytes, the size, and a video's length from its own header, then copies it to a private key no upload link can reach. Nothing is transcoded. Retrying with the same `clientSubmissionId` returns the same result.

**Rewards.** Taking part earns the reward, whatever the customer says and whether or not anything is published. AppRefer never credits anything itself: your backend pulls rewards with `POST …/support/testimonials/rewards/claim` (`{ environment?, limit? 1-10 }`, 2-minute leases), applies each `fulfillmentId` at most once, and reports it with `POST …/rewards/{fulfillmentId}/complete` `{ leaseId, status: fulfilled|failed, receiptId?, code?, message? }`.`fulfilled` needs your own `receiptId`. A lost result is handed over again with the same id; a reward nobody confirms within 7 days ends `timed_out`, and a support lead can hand a failed or timed-out one over again. Merch shipping details stay in your own signed-in flow, never in chat. The SDK tells your app when the reward is confirmed (`onRewardUpdate`) so it can refresh credits or usage.

**Usage terms and the library.** Each campaign has usage terms: `permission.text` and the uses it allows (`scopes`), registered by your backend or edited by a company admin in the Support Testimonials view. AppRefer computes their version from the text and scopes. The capture screen shows them word for word under “By submitting, you agree to these terms.” and a plain Submit: no switch, tick or extra screen. Nothing is recorded until the customer taps Submit; then the text, its version, the scopes, the time, the session and the reward named are stored. A request keeps the terms it was sent with, so editing them only affects later requests and never widens what someone already agreed to; a request without terms can't be submitted, and if the terms shown are out of date the submit is refused (`409 TERMS_CHANGED`) and the SDK shows the current ones for another Submit. Declining or later withdrawing (in the app or by the team on the customer's behalf) never takes the reward back. A support lead approves media for the library only when the customer agreed to the terms and hasn't withdrawn. `GET /api/v1/apps/{appId}/support/testimonials/library` returns approved items with permission scopes, whether they were rewarded (disclose it where you use them) and 10-minute download links, without customer identifiers. Withdrawn permission removes an item from future exports; copies already used elsewhere can't be recalled by AppRefer. This is separate from review cards: no rating, review or positive answer is ever asked for or rewarded.

**Automation.** A campaign with `automation: { event, minCount, withinDays }` and its automatic switch on asks a customer after `minCount` of that event within `withinDays`, counted from lifecycle engagement events (the event must be subscribed and engagement on for the app). Each event counts once; the count resets after an ask and when the window passes. The request is the invitation: SDK 0.13.0 shows the newest open request as a card on the customer's Support home each time it opens (never a pop-up), and `testimonials.pending()` lets your own screens show it. A push invitation is queued once per request through lifecycle engagement, decided fresh (marketing consent, notification permission, quiet hours, caps, the same account, the request still open and unseen, automation still on) for installs on SDK 0.13.0 or later, whose SDK opens it as the capture screen. Lifecycle engagement only records dry runs today, so no notification is sent yet.

**Retention and erasure.** Submissions, rewards and the customer's record are kept 400 days; stored media follows the same 400-day storage rule, so export what you want to keep longer. User erasure deletes the media, requests, rewards, upload slots, triggers and the customer's record. A reward your backend had already received may still be applied there; its completion then answers 404.

## Customer endpoints

These are what the SDKs call; you only need them for your own chat UI. All requests send `X-AppRefer-Key: pk_…`. Conversation, attachment and push-token calls also send `X-AppRefer-Support-Session: ars_…`. A conversation that isn't the session's answers `404`, exactly like one that doesn't exist.

| Request | Purpose |
| --- | --- |
| POST /api/support/v1/session | Body: appUserId?, deviceId?, name?, email?. Without a session header it returns a new token ({ session, created: true }, 201). With a valid header it updates the stored profile and returns created: false, unless that session already belongs to a different appUserId: then a new session is returned and the old one can no longer be verified. |
| POST /api/support/v1/session/identity | Verify the session in the header with a single-use identity token from your backend. Body: { identityToken }. 200 { identity: { verified: true, appUserId, verifiedAt } } (also for a retry of the same token from the same session), 200 { identity: { verified: false, reason } } when it can't, 503 to retry. See Verified identity. |
| DELETE /api/support/v1/session | Revoke the session in the header (its push tokens stop too). The conversations stay in the inbox. Always { ok: true, revoked: true }. |
| GET /api/support/v1/conversations | The customer's conversations, newest first (up to 50), each with subject, status, preview, lastFrom and unread (team replies not yet read), plus the AI persona when there is one. |
| POST /api/support/v1/conversations | Start a conversation. Body: text (may be empty when attachmentIds are sent), subject? (defaults to the first line), context? (appVersion, os, device, locale, platform), attachmentIds?, clientMessageId?. 201 with the conversation and its first message. |
| GET /api/support/v1/conversations/{id}?after=ISO | Messages the customer may see (up to 200; internal notes never). after returns only newer ones, for polling. Reading clears the unread count unless a newer team reply has landed. Each message has id, from (customer, team or bot), authorName (a first name), text, createdAt, attachments, handoff when the AI passed it on, and offer for an offer card. Send ?platform= and X-AppRefer-Support-Features: offers (SDK 0.12.0+); without that header a card message comes back as text with an update line instead. |
| GET /api/support/v1/conversations/{id}/offers?platform= | Every offer card in the conversation with its state now (see Offer cards), without reloading messages. platform is ios, android or web and picks the store's products. |
| POST /api/support/v1/conversations/{id}/offers/{cardId}/events | A receipt: { kind: shown | opened, clientEventId, occurredAt? }. 201 recorded, 200 duplicate: true for a repeated clientEventId; both return the card's current state. 409 OFFER\_UNAVAILABLE (with the state) when an opened card can no longer be opened. |
| GET /api/support/v1/conversations/{id}/reviews?platform= | Every review card in the conversation with its state now (see Review cards). platform is ios or android and picks the store page. |
| POST /api/support/v1/conversations/{id}/reviews/{cardId}/events | A receipt: { kind: shown | opened, clientEventId, occurredAt? }. 201 recorded, 200 duplicate: true. 409 REVIEW\_UNAVAILABLE (with the state) when an opened card can no longer be opened. Never a review or rating. |
| GET /api/support/v1/testimonials | Open photo/video requests for the account this session is verified for (newest first, up to 5). Empty for an unverified or anonymous session. See Photo and video testimonials. |
| GET /api/support/v1/testimonials/{requestId} | One request: title, prompt, media, maxVideoSeconds, reward { type, description }, permission { text, scopes, version }, status, publication and rewardStatus. 404 for another account's request; 409 IDENTITY\_REQUIRED until the account is verified on this session. |
| POST /api/support/v1/testimonials/{requestId}/uploads | { contentType, size } → 201 { uploadId, uploadUrl, headers, expiresIn: 900 }. PUT exactly that many bytes with those headers. Up to 10 slots per request. |
| POST /api/support/v1/testimonials/{requestId}/submit | { uploadId, clientSubmissionId, publication: boolean, permissionVersion } → 201 with the request (200 duplicate: true for the same clientSubmissionId). 409 UPLOAD\_INCOMPLETE, ALREADY\_SUBMITTED, REQUEST\_CLOSED, CAMPAIGN\_ENDED or IDENTITY\_REQUIRED; 422 TYPE\_MISMATCH, UNREADABLE\_VIDEO or VIDEO\_TOO\_LONG. |
| POST /api/support/v1/testimonials/{requestId}/decline | { dontAskAgain?: boolean }. Closes the request; dontAskAgain stops every later ask for this app and account. |
| POST /api/support/v1/testimonials/{requestId}/permission | { publication: "revoked" }: withdraws permission for public use. The reward is unaffected. |
| POST /api/support/v1/conversations/{id}/messages | The customer replies. Body: text and/or attachmentIds, clientMessageId?. A closed conversation reopens. |
| POST /api/support/v1/attachments | Ask to upload a screenshot. Body: contentType (image/jpeg, image/png, image/heic or image/webp), size in bytes (up to 10 MB), name?. Returns attachmentId, uploadUrl, headers and expiresIn: PUT the bytes there within 10 minutes with those headers, then pass attachmentIds (up to 4) on the conversation or message. Unused uploads are deleted after a day. Attachments come back with signed links valid for an hour. |
| POST /api/support/v1/push-token | Register this device for reply notifications. Body: platform (ios or android), token (APNs device token in hex, or FCM registration token), environment? (production, the default, or sandbox). Up to 5 devices per session. DELETE with { token } stops them. |
| GET /api/support/v1/status | Push, email and help-centre readiness (see Readiness). No session needed. |
| GET /api/support/v1/articles, GET /api/support/v1/articles/{slug} | The help centre (see Help centre). No session needed. |

```http
POST /api/support/v1/conversations
X-AppRefer-Key: pk_live_…
X-AppRefer-Support-Session: ars_…
Content-Type: application/json

{ "text": "I was charged twice", "clientMessageId": "c8f2e1a4-71b0-4d55",
  "context": { "appVersion": "2.4.1", "platform": "ios" } }
```

**Retries.** Send an optional `clientMessageId` (8 to 64 of A-Z, a-z, 0-9, \_ and -) with a new conversation or message, the same value on every retry. A retry of one that already went in returns the stored result with `200` and `duplicate: true`, before rate limits or attachments are checked again, so nothing is stored twice.

## Notifications and email

When your team replies in an in-app conversation and the customer hasn't read it, AppRefer sends a push about 2 minutes later, and one email after an hour if it's still unread (at most one email per conversation an hour, and only when the customer gave an email address). Reading the reply in the app, answering the email or closing the conversation cancels what is still pending. Push payloads carry the conversation id (`apprefer.conversationId` on iOS, `data.conversationId` on Android, with `type: "support_reply"` and Android channel `apprefer_support`), so your app can open the chat.

Customers can answer the email and the answer joins the same conversation. Emails sent to your support address start new email conversations in the inbox, which your team answers by email.

Set delivery up on the app's `support` integration with `PATCH /api/v1/apps/{appId}/integrations/support` (scope `integrations:write`). Send any subset; secrets are encrypted and always masked when read back.

| Fields | For |
| --- | --- |
| `apnsKeyId`, `apnsTeamId`, `apnsBundleId`, `apnsPrivateKey` (the .p8 file's PEM text) | iOS push through APNs, token auth. |
| `fcmProjectId`, `fcmServiceAccount` (service-account JSON) | Android push through Firebase Cloud Messaging. |
| `emailAccountId`, `emailApiToken`, `emailFrom` (for example `Sparkly Support <support@example.com>`) | Sending email through Cloudflare Email Service. |
| `emailReplyDomain` | A domain that receives replies through Cloudflare Email Routing. Emails go out with a `Reply-To` of `reply+<token>@` that domain, so an answer finds its conversation. |
| `inboundSecret` | Signs the posts from your inbound email Worker. Needed to receive email. |

### Receiving email

1.  Deploy the inbound Worker from the AppRefer repository (`workers/support-inbound`) to the Cloudflare account that hosts your reply domain.
2.  Set its `ROUTES` variable to map the domain to your app (`{"support.example.com":"app_…"}`) and its `INBOUND_SECRETS` secret to map the app to your `inboundSecret`.
3.  Point an Email Routing rule (a catch-all on the reply domain) at the Worker.

The Worker posts each message to `POST /api/support/inbound/{appId}` signed with `X-AppRefer-Timestamp` and `X-AppRefer-Signature` (hex HMAC-SHA256 of `timestamp.body` with the inbound secret; 5 minutes tolerance). A redelivered email is recognised by its Message-ID and stored once, and quoted history is stripped.

## AI answers

AppRefer can answer common questions on its own and draft replies for the rest. You run the model: an agent you control (the reference agent in `tools/support-agent` works with any OpenAI-compatible model, including one on your own machines) picks up waiting conversations with an organisation API key holding the `support:agent` scope, reads the app's help articles and the answers your team has sent, and posts a suggested reply. AppRefer then decides what reaches the customer, per app (`supportAi` on `PATCH /api/v1/apps/{appId}`):

| Mode | What happens |
| --- | --- |
| off (default) | Nothing is queued. |
| draft | Every suggestion waits in the inbox. Send it as is, edit it first, or discard it. |
| auto | Confident answers (sendThreshold, 0.85 by default) go out at once as an instant answer. Fairly confident ones (delayThreshold, 0.6) go out after delayMinutes (60) unless someone stops them. Everything else stays a draft. |

| Setting | Range | Default |
| --- | --- | --- |
| `mode` | off, draft or auto (required) | off |
| `sendThreshold` | 0.5 to 1 | 0.85 |
| `delayThreshold` | 0.3 to 1 | 0.6 |
| `delayMinutes` | 5 to 1440 | 60 |
| `maxInRow` | 0 to 10 instant answers before a person must step in | 2 |
| `maxAgeHours` | 1 to 168; a customer waiting longer gets a person | 12 |
| `persona` | { name, avatarUrl?, voice?, handoffTo? } or null | none |

`supportAi` is replaced whole on every update, so always send `mode` (and the persona, if you use one).

Some answers always wait for a person, whatever the mode: refunds, payment disputes, legal or data-protection questions, account deletion, account security, an upset customer, someone asking for a person, bug reports, conversations a teammate has already joined or that were handed over, closed conversations, a customer who has waited longer than `maxAgeHours`, and any answer that doesn't cite a real help article or past answer. AppRefer checks the customer's own words for these as well as trusting the agent. A person's reply, or Stop or Discard in the inbox, always wins over a scheduled answer. The inbox has an AI view of everything the AI answered.

**Persona.** Give the AI a name and portrait with `supportAi.persona`: `name` (a first name, up to 30 letters), `avatarUrl?` (https), `voice?` (how it writes, up to 300 characters), `handoffTo?` (the first name of the person it passes conversations to). In auto mode the apps show its name, portrait and an "AI" tag on every message, the agent writes in its voice, pushes read "Name: …" and emails sign as "Name (AI assistant)". When an answer in the in-app chat is kept for a person (or the AI can't answer), it tells the customer once, in fixed wording in their language, that it has passed the conversation to `handoffTo` (or "the team"); the conversation still waits for your reply. The customer API returns `persona` on the conversation list and on each conversation, and marks that message `handoff: true`.

| Request (organisation API key, support:agent) | Purpose |
| --- | --- |
| GET /api/v1/support/agent/queue?limit= | Claim waiting conversations (1 to 20, default 5) for 5 minutes, across your company's apps with AI on. Each item has the recent messages (no internal notes), the customer's first name and device, what they bought, account (live customer context: identityVerified, billing and, only for verified customers, appRecords), forMessageId and leaseId. A conversation that fails 5 passes is dropped. |
| POST /api/v1/support/agent/conversations/{id} | Body: forMessageId, leaseId, reply (up to 4,000 characters, or null), confidence (0 to 1), kind?, language?, flags? (refund, legal, deletion, angry, dispute, security, human\_requested, bug), summary?, sources? (up to 10 of { type: article|answer, id }), model?. Returns the action taken: send, schedule (with sendAt), draft or none. 409 means the conversation moved on; drop it. |
| GET /api/v1/support/agent/knowledge?appId=&sandbox= | The app's published help articles and your team's newest 400 answers, with email addresses, phone and long account numbers, names in greetings and links that carry tokens masked. sandbox=true includes test conversations. |
| PUT /api/v1/support/agent/knowledge | Add or replace help articles: { appId, articles: \[{ slug, title, body, status?, summary?, category?, order?, locale?, translationOf?, audience?, url? }\], publish?: \[slug\], unpublish?: \[slug\], remove?: \[slug\] }. Same rules as the help centre below, except that new articles are AI-only (audience "agent") unless they say "public", and unusable optional values are ignored rather than refused. |

```http
PATCH /api/v1/apps/{appId}
Authorization: Bearer ak_live_…

{ "supportAi": { "mode": "auto", "sendThreshold": 0.85, "delayThreshold": 0.6, "delayMinutes": 60, "maxInRow": 2, "maxAgeHours": 12,
    "persona": { "name": "Sam", "avatarUrl": "https://example.com/sam.png", "voice": "Warm and upbeat", "handoffTo": "Ana" } } }
```

## Help centre

The same help articles the AI answers from form your in-app help centre. The app reads them with its SDK key alone (no support session). Articles are markdown (up to 20,000 characters), one per `slug` (lowercase letters, digits and dashes), with a title and an optional `summary`, `category` and `order`. They sort by category, then order, then title.

| Request (X-AppRefer-Key) | Purpose |
| --- | --- |
| GET /api/support/v1/articles?locale=&q= | Published public articles, one per article in the customer's language: { articles: \[{ slug, title, summary, category, locale, url, status, updatedAt }\], locale }. locale falls back to the Accept-Language header. q searches titles, summaries, categories and bodies (best 20). The response may be cached for 5 minutes. |
| GET /api/support/v1/articles/{slug}?locale= | { article: { slug, title, summary, body (markdown), category, locale, url, status, updatedAt } }. Any translation's slug works; the best translation for the locale comes back. Drafts, AI-only articles and unknown slugs return 404. |

**Drafts.** An article is `status: "published"` or `"draft"`. A new article sent without a status is published. Anything you leave out when editing an article (status, audience, summary, category, order, locale, translationOf, url) keeps its stored value; send `null` to clear a field. Send `status: "draft"` to stage a new one and publish it later with `publish: [slug]` (which also applies to an article in the same request sent without a status). A test SDK key (`pk_test_`) also sees drafts, marked `status: "draft"`, so you can preview them in a debug build; live keys never do. Drafts are not used by the AI.

**Public or AI-only.** Only articles with `audience: "public"` appear in the help centre; `"agent"` articles are AI knowledge only (internal guidance the AI still answers from). An article without an audience is AI-only. New articles saved from the articles API with `apps:write`, or through MCP, default to `"public"`; new articles saved with only `support:agent` default to `"agent"`, so nothing written for the AI becomes public unless someone says so.

**Languages.** Give each article a `locale` (`en`, `pt-BR`). A translation is its own slug with `translationOf` set to the original slug. For each article the app gets the exact locale, else the same language, else the version without a locale (your default language), else English, else any.

| Request (organisation API key) | Purpose |
| --- | --- |
| GET /api/v1/apps/{appId}/support/articles | Every article, drafts and AI-only ones included, with status, audience, translationOf and dates. ?body=false leaves the bodies out. Scope apps:read or support:agent. |
| PUT /api/v1/apps/{appId}/support/articles | { articles?: \[{ slug, title, body, status?, summary?, category?, order?, locale?, translationOf?, audience?, url? }\], publish?: \[slug\], unpublish?: \[slug\], remove?: \[slug\] }, up to 200 changes. Returns { ok, upserted, removed, published, unpublished, missing }. Scope apps:write or support:agent. |

MCP: `list_help_articles`, `upsert_help_articles`, `publish_help_articles` (see [AI agents and MCP](https://apprefer.com/docs/mcp.md)).

## Readiness

Before an app registers for reply notifications it can ask whether push is set up and working. AppRefer checks the stored push credentials with Apple and Google without notifying anyone: APNs gets a push for a made-up device token on production and sandbox (Apple answering "bad device token" means it accepted the key; 403 or a wrong topic means it didn't), and FCM must mint a token from the service account and accept a dry run. Results are cached for 6 hours (30 minutes after a rejection, 5 minutes when a store can't be reached) and checked again as soon as the credentials change. A check never holds the request longer than about 3 seconds. No secret is ever returned.

```http
GET /api/support/v1/status
X-AppRefer-Key: pk_live_…

{ "push": {
    "ios": { "configured": true, "verified": true, "checkedAt": "2026-10-16T09:00:00.000Z",
             "environments": { "production": true, "sandbox": true } },
    "android": { "configured": true, "verified": false, "checkedAt": "…", "error": "No permission to send for this Firebase project" } },
  "email": { "configured": true, "verified": null, "receivesReplies": true },
  "helpCentre": { "publishedArticles": 12 } }
```

`verified` is `true` when the store accepted the credentials (iOS: production or sandbox), `false` when it rejected them (see `error`; on iOS also when the bundle id isn't the key's app), and `null` when not configured or not checked yet. A Google or Apple outage reads `null`, not `false`. Register for push only when `verified` is true. Email is `configured` when the sending fields and `emailReplyDomain` are set, and `receivesReplies` when `inboundSecret` is set too; nothing is sent to check it. The app may keep the answer for 5 minutes when every configured channel is verified, otherwise for 30 seconds.

For release checks, `GET /api/v1/apps/{appId}/support/readiness` returns the same with any organisation key that has a read scope (analytics, apps, billing, integrations, links or skan read, or support:agent), plus draft and AI-only article counts, published locales and the AI mode; `?refresh=true` checks again (once a minute at most). MCP: `get_app_readiness` (support and billing together).

On iOS the SDKs (0.10.1+) register APNs tokens for the environment the app is signed for, read from the embedded provisioning profile's `aps-environment`: development-signed builds (Debug or Release) and the Simulator use sandbox; App Store, TestFlight, Ad Hoc and Enterprise builds use production. `pushReady` checks that same environment. Set `support.pushEnvironment` to `production` or `sandbox` to force one.

## The inbox

The Support inbox is at `/dashboard/support` and covers every company you belong to; `/support` is the same inbox on its own page, for support staff. Views: needs reply, open, closed, AI and all, with a Test toggle for conversations from test keys. Each conversation shows the customer's purchases, plan and [live context](#live-customer-context) next to the messages. Team members with the member role or higher reply and close; viewers read. Internal notes are never shown to the customer. Replies, notes and status changes are recorded in the audit log.

## Outcomes

**Outcomes** in the inbox shows, for one app, environment and the last 7, 30 or 90 UTC days (by when it was sent), what happened after your team or the AI used actions, offer cards and review cards. Everything is a count of facts AppRefer recorded, recomputed when you open it, so a retried receipt, a late store notification, a refund or a customer's erasure is never counted twice or kept. Each row opens its conversation.

-   **Actions:** done, failed, no result (your backend received it but never confirmed), never picked up, cancelled.
-   **Offer cards:** sent, seen, opened, and **bought after**: a purchase AppRefer Billing confirmed from the store, by the same verified account in the same environment, of a product the card pointed at, within 7 days of sending, counted once per store transaction for the latest card before it. Renewals, free-trial starts, Family Sharing, promotional grants, other products and test purchases never count; a trial counts only when it started after the card and then converted. Refunds show against the purchase they reverse. Amounts stay in the store's currency. This is an association, not proof the card caused the purchase, and never "saved" revenue. Cards sent to a customer the app hadn't verified are never matched to purchases.
-   **Review cards:** sent, seen and store page opened. Opening the store page isn't a review; AppRefer can't see reviews or ratings.
-   **Conversations:** closed and reopened after the first action or card, from the close and reopen times AppRefer records; conversations closed before those were recorded are shown as such.

Anyone with Support access to the app can see it, support seats only for their apps; nothing about ad sources is included. Each kind covers the newest 300 records in the range.

## Support seats

Give a support person the Support inbox without the rest of AppRefer. On the Team page, a company admin adds a support seat with an email, the apps it covers (or all of them), a role (support lead or support agent) and a refund limit in USD (up to 10,000) that the inbox shows them. Both roles read and reply.

1.  The admin adds the seat. It is unclaimed until first use.
2.  The person signs in at `/support` with that email address. The address must be verified by the sign-in provider.
3.  Their first Support request claims the seat for that account; from then on it follows the account, even if the email changes.

Seat holders see what each customer bought, but not revenue reports, ad data, keys or settings, and not which campaign brought a customer in. A seat isn't a team membership, so every other dashboard page refuses them. Someone who is already a team member can't also get a seat; change their team role instead. Removing a seat ends access on their next request.

## Moving from Gleap

AppRefer can import an app's Gleap history once, so earlier conversations are in the inbox when you switch: the last 90 days plus every open ticket, with internal notes. An operator sets it up with the MCP tool `configure_integration`, network `gleap` (`projectId`, `apiToken`). Imported conversations are answered by email from the inbox and are never answered by the AI on its own.

## Limits

| What | Limit |
| --- | --- |
| Message text | 10,000 characters (413 if longer) |
| Request body | 32 KB (4 KB for sessions and upload requests, 8 KB for push tokens); 413 if larger |
| Requests | 60 a minute per device IP, 30 a minute per session |
| New sessions | 20 an hour per IP, 5,000 an hour per app |
| New conversations | 10 an hour per customer |
| Screenshots | 4 per message, 10 MB each, 20 uploads an hour per customer |
| Push devices | 5 per session |

---

Source: https://apprefer.com/docs/support · Last updated: 2026-10-29
