# AppRefer ## For AI agents Read https://apprefer.com/auth.md for the complete agent access contract. ## Let your agent sign up POST /api/agent/workspaces (public, JSON): { "email": "owner@example.com", "company": "Example Labs", "appName": "Example App", "agent": "Your agent" } email and company are required. appName and agent are optional. Returns 201: { "workspace_id": "org_…", "api_key": "ak_live_…", "claim_url": "https://apprefer.com/activate?code=XXXX-XXXX", "user_code": "XXXX-XXXX", "expires_at": "", "mcp_url": "https://apprefer.com/api/mcp", "test_mode": true } Save api_key securely on the server; it is returned once, hashed at rest, scoped to this organization and expires after seven days even after claim. Never put it in an app, browser bundle or log. Despite its ak_live_ prefix, it can only set up in test mode until the human claims and live access opens. Use pk_test_ SDK keys. GET /api/agent/workspaces/{workspace_id} with Authorization: Bearer returns { "status": "unclaimed" | "claimed_pending_approval" | "live" | "expired" }. The expired key can only read this status until cleanup removes it; it cannot operate on the workspace. Give claim_url and user_code to the human. They sign in with the matching verified Google email, review the apps built, then claim ownership or choose “This wasn't me” to delete the workspace. Claim joins the waitlist; live mode opens on approval, or immediately when signup is open. The agent can keep configuring sandbox data meanwhile. Unclaimed workspaces are removed after seven days. One open workspace per email; three creations per IP per day; up to three apps, twenty links and fifty products while unclaimed. Validation errors are 400 invalid_request, email conflict 409 workspace_exists, rate limit 429 rate_limited, storage failure 503 unavailable. Test mode permits apps, sandbox SDK data, tracking links, billing catalog (products, entitlements, offerings, placements, targeting, experiments), sandbox analytics and help articles. Ad integrations and store credentials, live SDK access, Support AI/delivery settings, web events, web purchases and erasure are refused. Signed Apple sandbox receipts can be verified without store credentials once the app bundleId is set through update_app; production receipts with pk_test_ are rejected. Google Play receipt verification still requires store credentials after claim and approval. Xcode-local purchases unlock on-device only under the existing SDK behavior. Team invitations and seats require claim. No platform admin key is given to a customer. The existing POST /api/agent/access-requests flow below remains an alternative for requesting account access without creating a workspace. It never issues credentials. ## Connect over MCP (OAuth) POST https://apprefer.com/api/mcp accepts organization API keys and OAuth access tokens, alongside operator admin keys. Use Authorization: Bearer . GET returns 405; each POST is stateless JSON-RPC. tools/list shows only tools allowed by the credential. The company always comes from that credential: omit orgId; appId/linkId must belong to it. Platform tools (list_orgs, cross-company portfolio, waitlist administration) are unavailable to customer agents. A 401 includes WWW-Authenticate: Bearer resource_metadata="https://apprefer.com/.well-known/oauth-protected-resource/api/mcp". Protected-resource metadata is also at /.well-known/oauth-protected-resource; authorization-server metadata is /.well-known/oauth-authorization-server. resource must be https://apprefer.com/api/mcp at authorization and token exchange. Use HTTPS Client ID Metadata Documents (client_id, client_name, redirect_uris, token_endpoint_auth_method: none); public DNS, bounded fetch and no redirects are enforced. POST /api/oauth/register is the per-IP rate-limited public-client fallback with client_name and redirect_uris (HTTPS or exact localhost loopback callback); no client secret is issued. Open /oauth/authorize with client_id, response_type=code, redirect_uri (exact registered value), scope (space-separated), code_challenge, code_challenge_method=S256, resource, and state. The human signs in, picks a company they belong to and allows or cancels. Claim a trial workspace first to make it selectable. The response includes iss and state. POST form-encoded /api/oauth/token with grant_type=authorization_code, code, client_id, redirect_uri, code_verifier, resource. Codes expire in ten minutes and work once. Access tokens are opaque, expire after one hour and are audience-bound to MCP. Refresh: grant_type=refresh_token, refresh_token, client_id, resource. Replace the refresh token every time; replay revokes the entire connection. Connections have a maximum thirty-day lifetime. Reconnect after that. POST /api/oauth/revoke with token and client_id, or use Settings → Connected agents to revoke immediately. Removing membership or write privileges also ends token authority. Tokens are hashed at rest. Scopes: apps:read (info, get_org, list_apps, get_app, readiness), apps:write (create/update apps); links:read/write; integrations:read/write; analytics:read (stats, overview, campaigns, events, attributions); skan:read; billing:read (catalog, subscription metrics, experiments), billing:write (catalog changes); support:agent (list/upsert/publish help articles). Viewer/member OAuth connections may request read scopes only. Trials remain test-only regardless of credential scopes. Risky customer MCP tools (delete_link, erase_user, update_billing_config, integration changes, Support AI auto) return { "approval_required": true, "approval_url": "https://apprefer.com/approve/" } inside result.content[0].text. A signed-in company admin approves the exact action; then repeat the same tool and arguments with approval_id set to the final URL segment. Approval expires after 24 hours, is bound to this credential and action and works once. Changing arguments or retrying a consumed approval fails. erase_user requires users:erase, appId, appUserId, requestId and explicit environment (all|live|sandbox); update_billing_config requires integrations:write, appId and config (same shape as REST billing config). Both require a human approval. Trial erasure and store configuration are refused. > AppRefer is one SDK and one dashboard for attribution, subscriptions (AppRefer Billing), in-app support and analytics on iOS, Android, Flutter and React Native. Tracking links record the ad click, the SDK matches the install to it, AppRefer Billing (or RevenueCat webhooks) records purchases, and conversions are sent back to the ad network that drove them. Support (in-app chat, email, help centre, AI answers) and analytics use the same customer record. This file (llms-full.txt) is the complete AppRefer documentation in one plain-text document, written for AI coding agents. It follows the order of the docs site at https://apprefer.com/docs. All four SDKs share one version number: this file documents SDK 0.11.0. Rules for agents: - SDK keys (pk_live_..., pk_test_...) are public and go in the app. Organisation API keys (ak_live_...) are server-side secrets: never put one in an app or a web page. - Use pk_test_ in debug builds and pk_live_ in release builds. Test-key data is sandbox data and is never forwarded to ad networks. - Server-side work (apps, tracking links, integrations, billing configuration, readiness checks) uses the REST API at https://apprefer.com/api/v1 with "Authorization: Bearer ". On a self-hosted AppRefer, use your own domain everywhere https://apprefer.com appears. - MCP (/api/mcp) accepts scoped organization keys or OAuth; the platform admin key is for operators only. - Don't track purchases with trackEvent when AppRefer Billing or RevenueCat is connected: purchases come from the store side. > 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). # Build with AppRefer One SDK and one dashboard for attribution, subscriptions, in-app support and analytics on iOS, Android, Flutter and React Native. Set it up yourself, or give your coding agent one prompt. [Quickstart](https://apprefer.com/docs/quickstart.md) [Set up with your coding agent](https://apprefer.com/docs/mcp.md) Paste this into your coding agent **Prompt** ``` Add AppRefer to this app. Read https://apprefer.com/llms.txt, install the SDK, use the AppRefer REST API (key in APPREFER_API_KEY) to make tracking links for our ad networks, and move our subscriptions onto AppRefer Billing. ``` Your agent reads [llms.txt](https://apprefer.com/llms.txt) (every docs page, as markdown), then uses our [REST API](https://apprefer.com/docs/api.md) to create your app, tracking links, integrations and products. ## Pick what you need All four share one customer record. Use one or use them all. ### Attribution See which ad brought each install and purchase, and send purchases back to Meta, Google, TikTok and ChatGPT Ads. [How attribution works](https://apprefer.com/docs/attribution.md)[Tracking links](https://apprefer.com/docs/tracking-links.md)[Ad networks](https://apprefer.com/docs/integrations.md) ### Billing App Store, Google Play and Stripe subscriptions in one customer record, with paywall experiments built in. [Billing setup](https://apprefer.com/docs/billing.md)[Offerings & experiments](https://apprefer.com/docs/offerings.md)[Subscription analytics](https://apprefer.com/docs/subscriptions.md) ### Support In-app chat, email and a help centre, with an AI agent that answers first and hands over to your team. [Add the chat](https://apprefer.com/docs/support.md)[AI answers](https://apprefer.com/docs/support.md#ai-answers)[Help centre](https://apprefer.com/docs/support.md#help-centre) ### Analytics Product events, revenue after refunds and ROAS by cohort, tied back to the campaign that brought each customer. [Events](https://apprefer.com/docs/events.md)[Dashboard tour](https://apprefer.com/docs/dashboard.md)[REST API](https://apprefer.com/docs/api.md) ## SDKs All four SDKs ship the same features in the same release. [iOS Swift Package Reference](https://apprefer.com/docs/sdk/ios.md)[Android Kotlin Reference](https://apprefer.com/docs/sdk/android.md)[Flutter Dart Reference](https://apprefer.com/docs/sdk/flutter.md)[React Native TypeScript Reference](https://apprefer.com/docs/sdk/react-native.md) ## Popular pages [QuickstartGet your key, add the SDK, see your first install.→](https://apprefer.com/docs/quickstart.md)[How AppRefer worksFrom ad click to paying customer.→](https://apprefer.com/docs/overview.md)[Move from RevenueCatSwitch over without exporting your data.→](https://apprefer.com/docs/billing.md)[Privacy & dataConsent, hashing and deletion.→](https://apprefer.com/docs/privacy.md) --- Source: https://apprefer.com/docs · Last updated: 2026-10-24 > 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). # Quickstart From an empty dashboard to your first attributed install, on iOS, Android, Flutter or React Native. You need an AppRefer account and an app you can build. Each step below shows all four SDKs; pick your platform's tab. The full references are [iOS](https://apprefer.com/docs/sdk/ios.md), [Android](https://apprefer.com/docs/sdk/android.md), [Flutter](https://apprefer.com/docs/sdk/flutter.md) and [React Native](https://apprefer.com/docs/sdk/react-native.md). > **Working with an AI coding agent?** > > Agents can create the app, tracking links and integrations for you through AppRefer's MCP server and REST API. See [AI agents and MCP](https://apprefer.com/docs/mcp.md), then come back for the in-app steps (install and configure). ## 1\. Create an app and copy its key In the dashboard, add your app (its App Store and Google Play links help AppRefer route clicks and show store details). Its SDK keys are on the app's **Settings** page, where you can also copy or rotate them. Each app has two: - `pk_live_…` for release builds: real attribution, events forwarded to your ad networks. - `pk_test_…` for development: sandbox data, shown in the Debugger's sandbox view and never forwarded. SDK keys are public by design (they ship inside your app). Keep organisation API keys (`ak_…`) on your server. ## 2\. Install the SDK **iOS** ```swift // Xcode: File > Add Package Dependencies… // https://github.com/AppAgentic/apprefer-ios-sdk (from 0.11.0, product "AppRefer") // or Package.swift .package(url: "https://github.com/AppAgentic/apprefer-ios-sdk", from: "0.11.0") ``` **Android** ```kotlin // settings.gradle.kts dependencyResolutionManagement { repositories { google(); mavenCentral(); maven("https://jitpack.io") } } // app/build.gradle.kts dependencies { implementation("com.github.AppAgentic.apprefer-android-sdk:apprefer-android-sdk:0.11.0") // with AppRefer Billing instead: // implementation("com.github.AppAgentic.apprefer-android-sdk:apprefer-billing:0.11.0") } ``` **Flutter** ```yaml # pubspec.yaml dependencies: apprefer: git: url: https://github.com/AppAgentic/apprefer-flutter-sdk.git ref: 0.11.0 # then: flutter pub get ``` **React Native** ```bash npm install github:AppAgentic/apprefer-react-native-sdk#0.11.0 @react-native-async-storage/async-storage cd ios && pod install ``` iOS (including Flutter and React Native) needs iOS 15 or later. Expo apps need a development build. ## 3\. Configure at launch Call `configure` once, as early as possible. The first launch resolves attribution; later launches return the cached result. It never throws. **iOS** ```swift import AppRefer @main struct MyApp: App { init() { Task { await AppRefer.configure(apiKey: "pk_test_...") } } var body: some Scene { WindowGroup { ContentView() } } } ``` **Android** ```kotlin import com.apprefer.sdk.AppRefer class App : Application() { override fun onCreate() { super.onCreate() CoroutineScope(Dispatchers.IO).launch { AppRefer.configure(this@App, apiKey = "pk_test_...") } } } ``` **Flutter** ```dart import 'package:apprefer/apprefer.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await AppReferSDK.configure(const AppReferConfig(apiKey: 'pk_test_...')); runApp(const MyApp()); } ``` **React Native** ```typescript import { AppRefer } from '@apprefer/react-native-sdk'; // in your root component useEffect(() => { AppRefer.configure({ apiKey: 'pk_test_...' }); }, []); ``` When someone signs in, call `setUserId` with your app's user id so Support and product events carry the account. > **iOS privacy manifest** > > Declare only `trk.apprefer.com` under `NSPrivacyTrackingDomains` in `PrivacyInfo.xcprivacy`. Never add `apprefer.com`: it carries the first-party requests (analytics, Billing, Support) that must keep working when a user doesn't allow tracking. The SDK reads App Tracking Transparency but never shows the prompt; if your app shows it, call `trackingConsentDidChange()` afterwards. On Android, pass your consent flow's answer to `setTrackingConsent`. ## 4\. Create a tracking link In the dashboard, open your app's **Links** page and create a link for each campaign: pick the network and name it. Use the generated URL as the ad's destination. It records the click (with the network's click id) and sends the user to the right store. - Meta, TikTok and most networks: `https://apprefer.com/api/c/` - Google Ads: `https://apprefer.com/g/`, a landing page with store buttons that Google accepts as a final URL - ChatGPT Ads: `https://apprefer.com/o/` Store page overrides, custom product pages and UTM handling are in [Tracking links](https://apprefer.com/docs/tracking-links.md). ## 5\. Connect your ad networks Under **Settings → Integrations**, connect each network you want conversions sent to (Meta, Google Ads, TikTok, ChatGPT Ads). On iOS, Apple Ads installs are matched through AdServices, with no setup. Each guide lists exactly what to paste: [Integrations](https://apprefer.com/docs/integrations.md). ## 6\. Check it in the Debugger Run a build with the `pk_test_` key and open the **Debugger** with the sandbox view on. The configure call and its attribution appear within seconds; tracked events follow. To test a real match, tap your tracking link on the device first, then install and open the app. Switch to `pk_live_` for release. ## 7\. Optional: subscriptions with AppRefer Billing AppRefer Billing handles App Store, Google Play and Stripe subscriptions itself: it verifies purchases, keeps entitlements and sends each conversion to your ad networks from the same attribution. Turn it on for the app first (store credentials and products), as described in [Billing](https://apprefer.com/docs/billing.md); paywall setup is in [Offerings](https://apprefer.com/docs/offerings.md). **iOS** ```swift AppReferBilling.configure(entitlementsByProduct: ["pro_yearly": ["premium"]]) let offering = try await AppReferBilling.offerings().currentOffering(forPlacement: "onboarding") if let annual = offering?.annual { _ = try await AppReferBilling.purchase(package: annual) } let premium = await AppReferBilling.customerInfo().entitlements["premium"]?.isActive == true ``` **Android** ```kotlin AppReferBilling.configure(context, entitlementsByProduct = mapOf("pro_yearly" to listOf("premium"))) val annual = AppReferBilling.offerings().currentOffering(forPlacement = "onboarding")?.annual if (annual != null) AppReferBilling.purchase(activity, annual) val premium = AppReferBilling.customerInfo().entitlements["premium"]?.isActive == true ``` **Flutter** ```dart await AppReferBilling.configure(entitlementsByProduct: {'pro_yearly': ['premium']}); final annual = (await AppReferBilling.offerings()).currentOffering('onboarding')?.annual; if (annual != null) await AppReferBilling.purchasePackage(annual); final premium = (await AppReferBilling.customerInfo()).entitlements['premium']?.isActive == true; ``` **React Native** ```typescript import { AppReferBilling } from '@apprefer/react-native-sdk/billing'; // needs react-native-iap 17+ await AppReferBilling.configure({ entitlementsByProduct: { pro_yearly: ['premium'] } }); const annual = (await AppReferBilling.offerings()).currentOffering('onboarding')?.annual; if (annual) await AppReferBilling.purchasePackage(annual); const premium = !!(await AppReferBilling.customerInfo()).entitlements.active.premium; ``` When a user signs in or out, also call Billing's `logIn` / `logOut`. Already on RevenueCat? Keep it running and send its webhook to AppRefer ([RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md)), or move over without a data export as the Billing page explains. ## 8\. Optional: in-app Support A help chat your team answers from the AppRefer inbox, with reply notifications, a help centre and an optional AI assistant. Set it up in [Support](https://apprefer.com/docs/support.md), then open it from a Help button: **iOS** ```swift AppRefer.support.open() ``` **Android** ```kotlin AppRefer.support.open() ``` **Flutter** ```dart await AppReferSDK.support.open(context: context); ``` **React Native** ```tsx // Mount once next to your navigation: AppRefer.support.open(); ``` ## Next steps - Track the moments that matter with `trackEvent`: [Events](https://apprefer.com/docs/events.md). - Understand how installs are matched: [Attribution](https://apprefer.com/docs/attribution.md). - Handle consent and account switching (`setCollectionEnabled`, `reset()`): see your platform's SDK page and [Privacy](https://apprefer.com/docs/privacy.md). - Automate setup and reporting: [REST API](https://apprefer.com/docs/api.md) and [AI agents and MCP](https://apprefer.com/docs/mcp.md). **Check an install from your terminal (test key)** ```bash curl -X POST https://apprefer.com/api/track/configure \ -H "Content-Type: application/json" \ -H "X-AppRefer-Key: pk_test_..." \ -d '{"device_id": "quickstart-test-001"}' ``` --- Source: https://apprefer.com/docs/quickstart · Last updated: 2026-10-19 > 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). # How AppRefer works AppRefer is one SDK and one dashboard for attribution, subscriptions, in-app support and analytics, on iOS, Android, Flutter and React Native. ## What you get - **Attribution.** Tracking links record the ad click, the SDK matches the install to it, and purchases are sent back to the ad network that drove them. See [Attribution](https://apprefer.com/docs/attribution.md). - **Subscriptions (AppRefer Billing).** App Store, Google Play and Stripe purchases verified on the server, entitlements, offerings and paywall experiments. You can also keep RevenueCat and send its webhooks to AppRefer. See [Billing setup](https://apprefer.com/docs/billing.md) and [Offerings](https://apprefer.com/docs/offerings.md). - **Support.** In-app chat, email replies, a help centre and optional AI answers, worked from one inbox that shows each customer's purchases. See [Support](https://apprefer.com/docs/support.md). - **Analytics.** Net revenue, cohort ROAS, subscription metrics, product events and a live event stream. See the [Dashboard guide](https://apprefer.com/docs/dashboard.md). - **Set up by people or AI agents.** Everything in the dashboard can also be done through the [REST API](https://apprefer.com/docs/api.md) with an organisation API key, and the whole reference is available as one plain-text file at [/llms-full.txt](https://apprefer.com/llms-full.txt) for coding agents (with an index of every page as markdown at [/llms.txt](https://apprefer.com/llms.txt)). ## The customer journey Every step below writes to the same customer record, so attribution, revenue, support and analytics all describe the same person. 1. **You create a tracking link** in the dashboard (or via the API) and use it as the destination of your ad. 2. **Someone taps the ad.** The link records the click and its click ID (`fbclid`, `gclid`, `ttclid`, `oppref` or `twclid`), shows a short app page and sends them to the App Store or Google Play. On Android the click ID travels with the install through the Play Install Referrer. See [Tracking links](https://apprefer.com/docs/tracking-links.md). 3. **They install and open the app.** The SDK's `configure()` makes one call. AppRefer matches the install to the click (Play Install Referrer, Apple AdServices, exact IP match, then probabilistic matching), respecting the user's tracking choice, and returns the result to the app. 4. **They start a trial or buy.** With AppRefer Billing, the purchase is verified with the store (App Store Server Notifications, Google Play real-time notifications, Stripe webhooks). If you use RevenueCat, its webhook delivers the same events. Either way the purchase lands on the customer's record. 5. **The conversion goes back to the ad network.** AppRefer sends it with the original click ID to that network's conversions API, so the network learns which ads bring paying customers. Trial starts can be held back until they prove intent ([Qualified trials](https://apprefer.com/docs/qualified-trials.md)), and renewals keep reporting lifetime value. 6. **Support and analytics use the same record.** When the customer opens the in-app chat, your team sees their subscription. Revenue, cohort ROAS and product events are reported per campaign, ad and country. ## Supported ad networks | Network | How installs are matched | Where conversions go | | --- | --- | --- | | [Meta Ads](https://apprefer.com/docs/integrations/meta.md) | `fbclid` on `/api/c` links, IP and probabilistic matching | Meta Conversions API | | [Google Ads](https://apprefer.com/docs/integrations/google.md) | `gclid` on the `/g` landing page; the `gclid` in the Play Install Referrer for Android App campaigns | Google Ads, per event conversion action | | [TikTok Ads](https://apprefer.com/docs/integrations/tiktok.md) | `ttclid` on `/api/c` links | TikTok Events API | | Apple Ads | Apple AdServices token on iOS (works without tracking permission) | Attribution and spend only; Apple has no conversions API | | [ChatGPT Ads (beta)](https://apprefer.com/docs/integrations/openai.md) | `oppref` on the `/o` landing page | OpenAI Conversions API | | [X Ads](https://apprefer.com/docs/integrations/x.md) | `twclid` on `/x` website-funnel links | X Conversions API for verified website purchases; off by default for each app | Apple's privacy-preserving postbacks (SKAdNetwork and AdAttributionKit) are covered on the [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md) page. ## Architecture AppRefer is a single web app: the API and the dashboard deploy together. There is no separate backend for you to run. ``` Your app (AppRefer SDK: iOS, Android, Flutter, React Native) | configure, events, Billing purchases, Support chat v AppRefer API + dashboard | operational store: matching, queues, customers, support | analytics warehouse: clicks, installs, events, revenue | |<-- App Store, Google Play and Stripe notifications (AppRefer Billing) |<-- RevenueCat webhooks (if you use RevenueCat) |<-- Your backend: verified website purchases, account deletion (REST API) | +--> Meta, Google Ads, TikTok, ChatGPT Ads and X Ads conversion APIs +--> Push notifications and email for support replies ``` ## Design principles - **Thin SDK.** `configure()` resolves attribution in one request and caches the result. Events are saved on the device and retried until delivered (up to 1,000 events for 7 days, SDK 0.9.0+). - **Purchases come from the server side.** Store notifications (AppRefer Billing) or RevenueCat webhooks are the source of truth for revenue. You don't need to track purchases in app code, and the **RevenueCat Only** setting ignores any revenue the app reports itself. - **Tracking only with permission.** Click matching, conversion forwarding and advanced matching run only for installs that allowed tracking. Everything else still gets first-party analytics, Billing and Support. See [Privacy](https://apprefer.com/docs/privacy.md). - **Personal data is hashed on the device.** Email, phone and name are SHA-256 hashed before they leave the phone. - **Server-side kill switch.** Turn the SDK off for an app from Settings without shipping an update. - **Separate test data.** Test keys (`pk_test_`) keep their installs, events and purchases apart from live data and never send anything to ad networks. - **One version for all four SDKs.** iOS, Android, Flutter and React Native ship the same features under the same version number (currently 0.11.0). > **Next step** > > Follow the [Quickstart](https://apprefer.com/docs/quickstart.md) to add the SDK, create your first tracking link and connect an ad network. --- Source: https://apprefer.com/docs/overview · Last updated: 2026-10-24 > 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). # AI agents & MCP Give a coding agent what it needs to install, configure and check AppRefer for you. No account yet? See [getting access as an agent](#getting-access-as-an-agent). There are three ways in, from simplest to most powerful: 1. **llms.txt** and **llms-full.txt**: an index of every docs page as markdown, and the whole documentation as one plain-text file, for any agent that can read a URL. 2. **The REST API** with an organisation API key: what an agent should use to create links, change settings and run release checks on apprefer.com. 3. **The MCP endpoint**: AppRefer's tools exposed over the Model Context Protocol, with organization keys or OAuth login. ## Getting access as an agent An agent can ask for an AppRefer account for the person it works for, without ever handling a password. It sends the person's email to `POST /api/agent/access-requests` and gets back a short code and a link. The person opens `apprefer.com/activate`, signs in with Google and taps Confirm, while the agent polls for the result. The full contract, with exact requests and responses, is in [https://apprefer.com/auth.md](https://apprefer.com/auth.md). > **Waitlist** > > Accounts are approved by hand right now, so a confirmed request waits on the waitlist until it is approved. After that, the person creates an organisation API key with only the scopes the agent needs, and the agent uses the [REST API](https://apprefer.com/docs/api.md). Confirming never gives the agent a key by itself. ## llms.txt [https://apprefer.com/llms.txt](https://apprefer.com/llms.txt) is the index for language models: how an agent gets access, then a link to every docs page as markdown. [https://apprefer.com/llms-full.txt](https://apprefer.com/llms-full.txt) is the whole reference in one plain-text file: SDK set-up for all four platforms, tracking links, integrations, billing, support and the APIs. Paste either URL into your agent's chat, or add it to the project's agent instructions (`CLAUDE.md`, `AGENTS.md`, `.cursor/rules`) so every session starts with it. Every docs page is also served as markdown: add `.md` to its URL (for example [/docs/quickstart.md](https://apprefer.com/docs/quickstart.md)) or request it with `Accept: text/markdown`. ## A setup prompt for your coding agent Put your SDK key in `APPREFER_SDK_KEY` and, if the agent should call the API, an organisation key with only the scopes it needs in `APPREFER_API_KEY` (see [scopes](https://apprefer.com/docs/api.md#scopes)). Then paste this: **Prompt** ``` You are adding AppRefer (attribution, billing and in-app support) to this project. 1. Read https://apprefer.com/llms-full.txt first. It is the complete AppRefer reference. 2. Work out the platform (native iOS, Android, Flutter or React Native) and add the AppRefer SDK at version 0.11.0 the way llms-full.txt describes for that platform. 3. Call configure once at app start with the public SDK key from APPREFER_SDK_KEY (pk_live_ for release builds, pk_test_ for debug builds). Never put an ak_ key in the app: organisation API keys are server-side secrets. 4. When a user signs in, call AppRefer.setUserId(id) and, if the app uses Billing, AppReferBilling.logIn(id) with the same id. On sign-out call AppRefer.reset() (and AppReferBilling.logOut()), then configure again for the next user. 5. On iOS the SDK reads the App Tracking Transparency status but never shows the prompt. If the app asks for tracking, keep that prompt in the app's own flow. 6. For server-side work (tracking links, integrations, readiness checks) use the REST API at https://apprefer.com/api/v1 with the key in APPREFER_API_KEY: "Authorization: Bearer $APPREFER_API_KEY". Only use the scopes the key has. 7. Before you finish, call GET /api/v1/apps/{appId}/support/readiness and GET /api/v1/apps/{appId}/billing/readiness, and tell me what is not set up yet. Do not change ad network or billing settings without asking me first. ``` For one app, **Settings → Setup guide** gives company admins a longer prompt tailored to that app's framework and current setup, including Support identity, live context, actions and offer cards (see [Set up with your coding agent](https://apprefer.com/docs/support.md#set-up-with-your-coding-agent)). > **Keys for agents** > > A read-only key (`analytics:read`, `apps:read`, `links:read`, `integrations:read`, `skan:read`) is enough for an agent that reports and checks. Add `links:write` only if it should create tracking links. Revoke the key in the dashboard when the job is done. ## Let your agent sign up An agent can start a seven-day test workspace immediately, build your setup, and give you a claim link. Sign in with the matching Google email to become its owner. Live mode opens after waitlist approval, or immediately when signup is open. **POST /api/agent/workspaces** ```json { "email": "owner@example.com", "company": "Example Labs", "appName": "Example App", "agent": "Your agent" } ``` The response contains `workspace_id`, a one-time `api_key`, `claim_url`, `user_code`, `expires_at`, `mcp_url`, and `test_mode: true`. Keep the key on your server. Poll `GET /api/agent/workspaces/{id}` with it for `unclaimed`, `claimed_pending_approval`, `live` or `expired`. Unclaimed limits: three apps, twenty links and fifty products; one workspace per email and three per IP each day. Test mode permits sandbox setup and help articles; it blocks live SDK access, integrations, store credentials, Support delivery/AI, web events, web purchases and erasure. Signed Apple sandbox receipts can use the app bundle identifier without live credentials. Google Play verification needs store credentials after claim and approval. Team invites and seats require claim. Unclaimed workspaces expire after seven days; choosing “This wasn’t me” deletes them. ## Connect over MCP (OAuth) Point an OAuth-capable client at `https://apprefer.com/api/mcp`. A 401 discovers the authorization server through protected-resource metadata. Sign in with Google, select your company, review permissions and allow. Claim a trial workspace first to select it. Manage access under [Settings → Connected agents](https://apprefer.com/dashboard/settings/connected-agents). Client ID Metadata Documents are supported; rate-limited `POST /api/oauth/register` is the fallback. PKCE S256 and `resource=https://apprefer.com/api/mcp` are required. Redirects match exactly; authorization responses include `iss`. Codes last ten minutes; access tokens one hour; rotating refresh tokens have a thirty-day connection lifetime. Refresh replay revokes the connection. Token and revoke endpoints are `/api/oauth/token` and `/api/oauth/revoke`. The complete wire contract is in [auth.md](https://apprefer.com/auth.md). ## Tools available to organization keys Use `apps:read/write` for apps, `links:read/write` for links, `integrations:read/write` for integrations, `analytics:read` for reporting, `skan:read` for postbacks, `billing:read/write` for the catalog and metrics, and `support:agent` for help articles. Tools are filtered to your scopes. Company identity comes from the credential; app and link identifiers must belong to it. Platform and cross-company tools are hidden and refused. Risky tools return `approval_required` and `approval_url` inside their text result. An org admin reviews the action, then the agent repeats the same call with `approval_id` from the URL. Approval lasts 24 hours, works once and is bound to the credential and exact arguments. Integration writes, deleting links and switching Support AI to auto require it. `erase_user` (`users:erase`, explicit environment) and `update_billing_config` (`integrations:write`) also require approval. Trial erasure and store configuration remain refused. ## MCP endpoint > **Customer and operator credentials** > > Customers use `Authorization: Bearer ak_live_...` or an OAuth access token. The operator platform admin key remains supported for cross-company administration; never give it to a customer agent. | | | | --- | --- | | Endpoint | `POST https://apprefer.com/api/mcp` | | Protocol | JSON-RPC 2.0 over HTTP. Each POST is one request and gets one JSON response (no streaming). | | Methods | `initialize` (protocol version `2024-11-05`, server `apprefer-admin`), `tools/list`, `tools/call` with `{ name, arguments }`. Notifications (`notifications/*`) get an empty `202`. | | Results | A tool's result is JSON, returned as text in `result.content[0].text`. | | Errors | `-32700` unreadable JSON, `-32600` not a JSON-RPC 2.0 request, `-32601` unknown method, `-32603` the tool failed (the message says why). | | GET | 405 Method Not Allowed. Use stateless POST. | | Time limit | 60 seconds per request (analytics tools query the warehouse). | ### Connect a client **Claude Code (.mcp.json)** ```json { "mcpServers": { "apprefer": { "type": "http", "url": "https://apprefer.com/api/mcp", "headers": { "Authorization": "Bearer ${APPREFER_API_KEY}" } } } } ``` **Claude Code (CLI)** ```bash claude mcp add --transport http apprefer https://apprefer.com/api/mcp \ --header "Authorization: Bearer $APPREFER_API_KEY" ``` **Cursor (.cursor/mcp.json)** ```json { "mcpServers": { "apprefer": { "url": "https://apprefer.com/api/mcp", "headers": { "Authorization": "Bearer ${env:APPREFER_API_KEY}" } } } } ``` On a self-hosted AppRefer, replace `https://apprefer.com` with your own domain. Keep the key in an environment variable, not in a file you commit. ### Call it directly ```bash # List the tools curl -s https://apprefer.com/api/mcp \ -H "Authorization: Bearer $APPREFER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' # Run a release check curl -s https://apprefer.com/api/mcp \ -H "Authorization: Bearer $APPREFER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_app_readiness", "arguments": { "appId": "app_3f9c2a1b7d4e" } } }' { "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "{\n \"support\": { ... },\n \"billing\": { ... }\n}" } ] } } ``` ## Tools Every tool the endpoint lists. Most mirror a [REST endpoint](https://apprefer.com/docs/api.md), and secrets are never returned. Ask the agent to call `apprefer_info` first. ### Organisations and apps | Tool | What it does | | --- | --- | | `apprefer_info` | What AppRefer is and the list of tools. Call it first. | | `list_orgs` | Every organisation on the deployment: id, name, plan, created. | | `list_waitlist` | People waiting for access: email, company, app link, note, source, approved, signed in (limit, default 200). | | `approve_waitlist` | Let an email create a company after signing in (email, approved: false to revoke). Doesn't email them. | | `get_org` | One organisation with its member and app counts (orgId). | | `list_apps` | An organisation's apps with SDK status (orgId). | | `get_app` | One app: SDK keys, store URLs, integrations (masked), probabilisticMatching and trackingConsentRequired. It does not return the support AI policy. | | `create_app` | Create an app (orgId, name, bundleId?, iosUrl?, androidUrl?) and get its SDK keys. | | `update_app` | Change name, bundleId, store URLs, sdkEnabled (kill switch), trackingConsentRequired, probabilisticMatching or supportAi (replaced whole, so send mode and persona together). | ### Links and integrations | Tool | What it does | | --- | --- | | `list_links` | An app's tracking links with clicks and installs. | | `create_link` | Create a tracking link (linkId 3 to 50 letters, digits and dashes; network; store destinations; landing page; external campaign ids). | | `delete_link` | Delete a tracking link. | | `get_integrations` | An app's integrations and their connection status, secrets masked. | | `configure_integration` | Set up one integration: meta, google, tiktok, openai, x\_ads (off until enabled: true), apple\_ads, revenuecat, superwall, support (push and email delivery) or gleap (one-time history import). | ### Analytics | Tool | What it does | | --- | --- | | `get_app_stats` | Installs, revenue, trials and purchases with a daily chart (days 1 to 365, default 14). | | `get_app_overview` | KPIs, network breakdown and top campaigns. | | `get_campaigns` | Campaign performance, optionally for one network and by ad group, ad, keyword, search term or country. | | `get_portfolio_stats` | Totals across every app of an organisation. | | `get_recent_events` | The latest SDK and purchase events of an app (1 to 100). | | `get_attributions` | Recent install attributions (1 to 500). | | `get_skan_postbacks` | Summary of Apple's SKAdNetwork and AdAttributionKit postbacks (days 1 to 400, default 30). | ### Billing | Tool | What it does | | --- | --- | | `get_subscription_metrics` | Subscription revenue, trials, conversions, churn, active subscriptions and MRR in USD (start, end, bucket, store, sandbox). | | `get_billing_catalog` | Entitlements, products, offerings and packages, current offering, placements, targeting rules and experiments. | | `update_billing_catalog` | Change catalogue sections. Each section sent is replaced whole, so read it first; validated like the dashboard. | | `get_experiment_results` | Per-variant results of an offering experiment: enrolled, trials, conversion, payers, net revenue, chance to beat control. | ### Support and release checks | Tool | What it does | | --- | --- | | `list_help_articles` | An app's help articles with status (draft or published) and audience (public or agent); body optional. | | `upsert_help_articles` | Add, edit, publish, unpublish or remove help articles (up to 200 changes). New articles are published and public unless they say otherwise. | | `publish_help_articles` | Publish articles by slug, or move them back to draft (published: false). | | `get_app_readiness` | Support readiness (push credentials checked with Apple and Google, email, help centre, AI mode) and billing readiness (mode, stores, which secrets are set, last store notification). refresh: true checks push again. | The meaning of each setting is on its topic page: [Tracking links](https://apprefer.com/docs/tracking-links.md), [Integrations](https://apprefer.com/docs/integrations.md), [Offerings](https://apprefer.com/docs/offerings.md), [Subscription analytics](https://apprefer.com/docs/subscriptions.md) and [Support](https://apprefer.com/docs/support.md). --- Source: https://apprefer.com/docs/mcp · Last updated: 2026-10-24 > 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). # Dashboard guide What each part of the AppRefer dashboard shows, and exactly how its numbers are worked out. ## Finding your way around The sidebar has two groups: - **Your companies:** **Business**, **Apps** and **Support**. These always cover every company you belong to. The group is labelled with your company's name, or "All N companies" if you belong to several. - **This app:** the app switcher, then the sections for the open app (Overview, Acquisition, Revenue, Customers, Product, Live events) and Settings. The account menu at the foot of the sidebar lists each of your companies with its **Team** and **API keys** pages, and Sign out. ### The app switcher Press **⌘K** (Ctrl+K on Windows) anywhere in the dashboard to open one searchable list: Business and All apps at the top, your recently opened apps, then every app grouped by company. Use the arrow keys and Enter to pick one. Switching apps keeps you on the same page, so you can compare the same report across apps. ### Several companies, one login One login can belong to several companies. Business, Apps and Support combine all of them. App pages use the app's own company: opening an app from another company (from the switcher, Business or Apps) switches to that company on the way, and the Team and API keys pages follow it. ### Filters The bar at the top of each app page shows that section's tabs and only the filters the page actually uses: - **Data: Live / Sandbox** on Overview, Campaigns, Links, Customers, Product, Live events and Revenue. - **Platform: All / iOS / Android** on Overview, Campaigns, Links, Customers, Product and Live events. - Date ranges and network filters are set on each page. ## Business One view of every company you belong to, for the last 30 days compared with the 30 days before: - **Headline numbers:** annual run-rate (30-day net revenue × 365 / 30), revenue after refunds, contribution after ads (with margin) and ad spend (with blended ROAS). - A card per company with revenue, ad spend, contribution and how many apps earn. - **Revenue by app:** monthly revenue for the last 13 months (top five apps, the rest grouped), including this month so far. - **Where the money went:** customers paid, minus refunds, minus ad spend, equals contribution. - **Apps map:** each app placed by growth and ROAS, sized by revenue (apps with at least $100 net and a ROAS). - **Paid channels:** app and network pairs ranked by attributed revenue per dollar of spend, for channels with at least $200 of spend. - **Needs attention:** up to eight flags (see below). ## Apps A league table of every app across your companies, which you can sort and filter by company: 30-day revenue and change, a 12-month sparkline, ad spend, ROAS, contribution, installs, trials, new payers, refund rate and a verdict. Apps with no revenue or spend are hidden behind a toggle. Click a row to open the app. | Verdict | Meaning | | --- | --- | | Ads paid back · growing | ROAS of 1 or more, and revenue up at least 10% (or no earlier revenue to compare) | | Ads paid back · not growing | ROAS of 1 or more, revenue not up 10% | | Growing · ads not paid back | ROAS below 1, revenue growing | | Ads not paid back | ROAS below 1, not growing | | New | No ROAS yet, no revenue in the previous 30 days and some now | | Little or no ad spend | No ROAS (under $200 of spend) | | Spend currency unknown | Some spend is in a currency with no exchange rate | ### Needs-attention flags - Net revenue down more than 30% on the previous 30 days (when the previous 30 days had at least $300). - At least $500 spent in 30 days with less than 0.5× back. - Refunds above 8% of revenue (on at least $500 of revenue). - No ad spend synced for the last 3 days after at least $200 in the two weeks before (the campaign was paused, or the spend sync stopped). - Some spend is in a currency without an exchange rate and is left out. ## Overview (per app) Installs, revenue, trials, purchases, ad spend and ROAS, each compared with the previous period, plus installs and revenue over time, install sources, top campaigns and recent activity. Pick a date range (Today, last 7, 14 or 30 days, or custom; 14 days by default) and one or more ad sources (Meta, Google, TikTok, Apple Ads, organic, influencer and others). Every card uses the same selection. ROAS is shown only when spend is in USD; mixed spend currencies are labelled instead of being added together. ## Acquisition ### Cohort ROAS Groups installs into cohorts by network, campaign, ad set, ad or country over the last 30, 60, 90 (default) or 180 days, and shows spend, clicks, installs, CPI, trials, new payers, CPA, day-7 ROAS, day-30 ROAS, ROAS to date and revenue. Network rows expand into their campaigns. A payback chart compares D7, D30 and to-date ROAS with break-even per network. - Each payment is credited to the install's attribution in force when the payment was made, and counted towards day 7 or day 30 by time since install. - **D7 and D30 only use mature data:** installs and spend days more than 7 (or 30) days old, so young cohorts don't drag the ratio down. D30 needs a range of 60 days or more to show. - ROAS, CPI, cost per trial and CPA use only rows with ad spend, so organic installs and campaigns without synced spend can't inflate them. The page lists campaigns that have installs but no synced spend. - Verified website purchases with no install count as their own day-0 cohort, credited to the campaign on the purchase. - Rows with clicks only (usually stray `utm_source` values) are left out. ### Campaigns Performance per campaign for each network (All, Meta, Google, TikTok, Apple Ads, Influencer): clicks, installs, revenue, spend, CPA, ROAS and a trend, with a daily view per campaign. You can sync spend from connected networks, log manual spend (for example an influencer payment) and export CSV. Website-funnel revenue appears here. ### Links Create tracking links and see clicks, installs, trials, purchases, revenue and install rate per link. See [Tracking links](https://apprefer.com/docs/tracking-links.md). ## Revenue | Tab | What it shows | | --- | --- | | Revenue | Subscription metrics from AppRefer Billing: MRR, active subscribers, active trials, 30-day net revenue, trial conversion and churn, plus charts for revenue, new subscriptions, trials, renewals, cancellations, expirations, refunds and MRR, and revenue by store and product. Filter by store (App Store, Google Play, Stripe) and by day, week or month. | | Products | Entitlements and the store products that unlock them. | | Offerings | The packages your paywalls show, by placement and audience. | | Experiments | A/B tests between offerings, comparing revenue per customer. Needs at least two offerings. | Viewers and members can read the catalogue; admins edit it. Metric definitions are on the [Subscriptions](https://apprefer.com/docs/subscriptions.md) page, and catalogue setup on [Offerings](https://apprefer.com/docs/offerings.md). ## Customers Every install with its user ID, network, platform, campaign, match type, revenue, event count and first-seen date. Search by device ID, user ID or campaign, export 30 days as CSV, or open one customer to see their attribution and full event timeline. ## Product Product analytics from your in-app events over 7, 30 or 90 days: a conversion funnel, weekly retention cohorts, lifetime value by cohort and a cumulative LTV curve, which you can filter by network. ## Live events Events as they arrive, refreshed every 5 seconds (pause any time). Filter by device ID, event type or network, and export CSV. Use it with a test key while you integrate the SDK; with Sandbox selected you see only test events, which are never sent to ad networks. ## Support One inbox for every app's in-app chat and email, with views for Needs reply, Open, AI, Closed and All. Reply, add internal notes, review AI drafts, and see the customer's purchases alongside the conversation. Support staff can be given a support seat for chosen apps only, which opens a standalone inbox at `/support` without revenue, acquisition, keys or settings. See [Support](https://apprefer.com/docs/support.md). ## Settings | Tab | What you can do | | --- | --- | | App settings | App name and store URLs; live and test SDK keys (copy or regenerate); the SDK kill switch; RevenueCat Only; Qualified Trial; Qualified Purchases (observe only); ad network credentials; event mapping for Meta and TikTok; Google Ads conversion actions; SKAdNetwork conversion values. | | Integrations | Step-by-step setup for each ad network, RevenueCat, SKAdNetwork and each SDK. | | Setup guide | The steps to install the SDK, connect purchases and create your first links. | Some settings are only available through the API, such as `probabilisticMatching` and `trackingConsentRequired` (see [Attribution](https://apprefer.com/docs/attribution.md#app-settings-that-change-matching)). ## Team and API keys **Team** lists members and pending invitations and, for admins, support seats. **API keys** creates and revokes organisation keys with scopes such as `analytics:read`, `apps:write`, `links:write`, `billing:read`, `billing:write`, `support:context`, `support:actions` and `users:erase`. See the [API reference](https://apprefer.com/docs/api.md). | Role | Can | | --- | --- | | Viewer | See everything in the company's dashboard | | Member | Also create links, sync and log spend, and reply to support | | Admin | Also change app settings, regenerate SDK keys, manage API keys, edit the catalogue, invite members and manage support seats | | Owner | Also invite and remove admins and change roles | ## How the numbers are defined | Term | Definition | | --- | --- | | Revenue | Net USD: paid revenue (first payments, intro and promotional offers, renewals, one-time purchases, refund reversals) minus refunds. Store fees and tax are not deducted. | | Purchases / New payers | People paying for the first time: one per user, on their first paid event. Renewals never add a payer; a trial conversion does. A person is their user ID, or the device ID when there is none. | | Trials | Trial starts in the period. | | Contribution after ads | Net revenue minus ad spend in USD. Margin is contribution divided by net revenue. | | ROAS | Net revenue divided by ad spend. On Business and Apps it uses the app's whole net revenue and is shown only from $200 of spend in 30 days, because a tiny test can read 100× and more. | | Ad spend | Synced daily from connected networks (plus manual spend) and converted to USD at the current rate. Spend in a currency without a rate is left out and flagged. | | Days and windows | UTC calendar days, including today. 30-day comparisons are the last 30 days against the 30 before. | > **Live and sandbox never mix** > > Business and Apps use live data only. Elsewhere, the Live/Sandbox switch keeps test installs, events and purchases apart from real ones. --- Source: https://apprefer.com/docs/dashboard · Last updated: 2026-10-27 > 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). # iOS SDK Native Swift package for attribution, product events, SKAdNetwork and AdAttributionKit conversion values, AppRefer Billing (StoreKit 2) and the in-app Support chat. No third-party dependencies. Every page in this section follows the same order, so you can switch between the [iOS](https://apprefer.com/docs/sdk/ios.md), [Android](https://apprefer.com/docs/sdk/android.md), [Flutter](https://apprefer.com/docs/sdk/flutter.md) and [React Native](https://apprefer.com/docs/sdk/react-native.md) references and find the same thing in the same place. All four SDKs share one version number; this page documents **0.13.1**. ## Install Add the package with Swift Package Manager. 1. In Xcode, choose **File → Add Package Dependencies…** 2. Enter `https://github.com/AppAgentic/apprefer-ios-sdk` 3. Choose **Up to Next Minor Version** from `0.13.1` and add the `AppRefer` library to your app target. **Package.swift** ```swift dependencies: [ .package(url: "https://github.com/AppAgentic/apprefer-ios-sdk", from: "0.13.1") ], targets: [ .target( name: "YourApp", dependencies: [.product(name: "AppRefer", package: "apprefer-ios-sdk")] ) ] ``` > **Pre-1.0 versions** > > Before 1.0, a minor version can change behaviour. Pin to the minor version (`.upToNextMinor(from: "0.13.1")`) and read the changelog highlights below before upgrading. ### Platform setup - The SDK links Apple's `AdServices`, `AdSupport` and `AppTrackingTransparency` frameworks itself. No entitlements or capabilities are needed for attribution. - In your privacy manifest (`PrivacyInfo.xcprivacy`), declare **only** `trk.apprefer.com` under `NSPrivacyTrackingDomains`. Never add `apprefer.com`: it serves the first-party requests that must keep working when the user hasn't allowed tracking. See [Tracking consent](#tracking-consent). - To receive SKAdNetwork and AdAttributionKit postback copies, add the Info.plist keys in [SKAdNetwork and AdAttributionKit](#skadnetwork-and-adattributionkit). **PrivacyInfo.xcprivacy** ```xml NSPrivacyTracking NSPrivacyTrackingDomains trk.apprefer.com ``` ## Configure Call `configure` once, as early as possible at launch. On the first launch it sends the install's signals to AppRefer and resolves attribution; on later launches it returns the cached result without a network call. Calling it again with the same key reuses the existing instance. **MyApp.swift** ```swift import SwiftUI import AppRefer @main struct MyApp: App { init() { Task { let attribution = await AppRefer.configure(apiKey: "pk_live_...") if let attribution { print(attribution.network, attribution.matchType) } } } var body: some Scene { WindowGroup { ContentView() } } } ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `apiKey` | String | required | SDK key from the dashboard (`pk_live_…` or `pk_test_…`) | | `userId` | String? | nil | Your app's user id, if someone is already signed in | | `debug` | Bool | false | Print SDK logs to the console | | `logLevel` | Int | 1 | 0 none, 1 errors, 2 warnings, 3 verbose | `configure` returns `Attribution?` and never throws. Every SDK method uses `await` without `try`, except the Billing and Support calls that say so. Methods called before `configure` do nothing. > **Live and test keys** > > The server picks the environment from the key. `pk_test_` installs, events and purchases are sandbox data: visible in the dashboard's sandbox view and Debugger, never forwarded to ad networks. Use `pk_live_` in release builds. ### configureResult `configureResult` takes the same parameters and tells you why there is or isn't an attribution. It reports organic only after the server has said so, never because the network failed. ```swift switch await AppRefer.configureResult(apiKey: "pk_live_...") { case .attributed(let attribution): showWelcome(for: attribution.campaignName) case .pending: break // waiting for the user's ATT answer (see Tracking consent) case .organic: break // the server answered: no ad click matched case .failed(let error): print(error) // offline or unavailable; try again next launch } ``` | Result | Meaning | | --- | --- | | `.attributed(Attribution)` | Matched to an ad click, Apple Ads or a Play referrer | | `.pending` | The server is holding the decision until the user answers the ATT prompt (up to 24 hours) | | `.organic` | Confirmed organic install | | `.failed(Error)` | No answer from the server and nothing cached | `AppRefer.getAttribution()` returns the cached attribution at any time without a network call. ## Attribution result The `Attribution` struct returned by `configure` and `getAttribution()`: | Property | Type | Description | | --- | --- | --- | | `network` | String | Ad network, e.g. `meta`, `google`, `tiktok`, `apple_ads`, `openai`, or `organic` | | `matchType` | String | How the install was matched, e.g. `adservices`, `ip_match`, `probabilistic`, `organic` (see [Attribution](https://apprefer.com/docs/attribution.md)) | | `attributionId` | String? | AppRefer's id for this attribution | | `campaignId` | String? | Campaign id from the ad network or tracking link | | `campaignName` | String? | Campaign name | | `adGroupId` | String? | Ad set / ad group id | | `adId` | String? | Ad id | | `keyword` | String? | Search keyword (Apple Ads, Google) | | `fbclid` | String? | Meta click id, when matched to a Meta click | | `gclid` | String? | Google click id | | `ttclid` | String? | TikTok click id | | `campaign` | String? | Older field kept for compatibility; use `campaignName` | | `queryParams` | \[String: String\] | Extra tracking-link parameters, when present | | `customData` | \[String: String\] | Custom data, when present | | `trackingDecisionPending` | Bool | True while the server waits for the ATT answer | | `createdAt` | Date | When the attribution was made | ## Tracking consent AppRefer keeps cross-company tracking and first-party work apart for each install. The SDK reads the App Tracking Transparency status your app already has. **It never shows the ATT prompt.** | ATT status | Host | What AppRefer does | | --- | --- | --- | | Authorized | `trk.apprefer.com` | Matches the install to its ad click, forwards conversions to your ad networks, uses advanced matching | | Denied or restricted | `apprefer.com` | Apple Ads (AdServices) or organic attribution only; analytics, Billing and Support keep working; nothing is forwarded | | Not determined | `apprefer.com` | Treated as denied until the user answers; the decision is held for up to 24 hours in case they allow tracking | If you show the ATT prompt, tell AppRefer straight after it. The SDK also re-checks on every `configure` and whenever the app becomes active, because the user can change it in Settings. ```swift import AppTrackingTransparency _ = await ATTrackingManager.requestTrackingAuthorization() await AppRefer.trackingConsentDidChange() let consent = AppRefer.trackingConsent() // .granted, .denied or .undetermined ``` The IDFA is read only when ATT is authorized. Your app needs an `NSUserTrackingUsageDescription` only if it shows the prompt. More on how consent changes matching: [Privacy](https://apprefer.com/docs/privacy.md). ## User identity Tell AppRefer who is signed in, so Support conversations and product events carry the account (`app_user_id`). Use the same id you pass to `AppReferBilling.logIn`. You can also pass it as `userId` to `configure`. ```swift await AppRefer.setUserId(currentUser.id) try await AppReferBilling.logIn(currentUser.id) // if you use AppRefer Billing ``` ### Advanced matching Send hashed customer details to improve Meta match rates. Values are SHA-256 hashed on the device; nothing is sent unless ATT is authorized. Call it once after sign-up or sign-in. ```swift await AppRefer.setAdvancedMatching( email: "user@example.com", phone: "+15551234567", firstName: "Jane", lastName: "Doe", dateOfBirth: "19900131" // YYYYMMDD ) ``` ### Device id `await AppRefer.getDeviceId()` returns AppRefer's install id. It is kept in the Keychain, so it survives a reinstall, and is `nil` while the Keychain can't be read. AppRefer Billing sends it with purchases automatically. If you take payments through RevenueCat instead, set it as the `appreferId` subscriber attribute (see [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md)). ## Events Track the in-app moments that matter to you (sign-up, onboarding finished, first workout). Don't track purchases here: AppRefer Billing records purchases, renewals and refunds and forwards them for you. Which events reach ad networks is covered in [Events](https://apprefer.com/docs/events.md). ```swift await AppRefer.trackEvent("signup") await AppRefer.trackEvent("tutorial_complete", properties: ["step": "final"]) // Need to know what happened to it? let result = await AppRefer.trackEventResult("level_up", properties: ["level": 3]) // .sent (server acknowledged), .queued (saved, sent later) or .dropped ``` | Parameter | Type | Description | | --- | --- | --- | | `_ eventName` | String | Event name, e.g. `signup` | | `properties` | \[String: Any\]? | JSON-compatible metadata | | `revenue` | Double? | Amount for revenue events you track yourself | | `currency` | String? | ISO 4217 code, e.g. `USD` | ### Offline queue - Events are saved on the device before they are sent and keep one stable event id, so delivery is **at least once**. - The queue holds up to 1,000 events for up to seven days; the oldest are dropped first. - Network errors, HTTP 408, 429 and 5xx are retried with backoff (up to an hour, or longer if the server asks), at launch and when the app returns to the foreground. Other 4xx answers are final. - `await AppRefer.clearPendingEvents()` cancels event requests and empties the queue, for example when the user withdraws analytics consent. ## Collection control `setCollectionEnabled(false)` stops attribution, consent reports, conversion values and product events, cancels what is in flight and clears the event queue. The choice is saved across launches. Billing and Support keep working, without the install's device id. ```swift await AppRefer.setCollectionEnabled(false) // consent withdrawn // Later, to resume: enable, then configure again await AppRefer.setCollectionEnabled(true) await AppRefer.configure(apiKey: "pk_live_...") ``` Enabling alone sends nothing. Calling it with `true` while collection is already on changes nothing, so it is safe to call from a consent tool on every launch. ## Account switching Call `reset()` when a user signs out, and await it before the next account signs in. It revokes the old Support session and its push token, clears the Support profile, history and drafts, the user id and queued events. The install's own attribution and device id stay. ```swift // Sign out try? await AppReferBilling.logOut() // if you use AppRefer Billing await AppRefer.reset() // Next account signs in await AppRefer.configure(apiKey: "pk_live_...", userId: nextUser.id) try await AppReferBilling.logIn(nextUser.id) ``` - After `reset()`, attribution and events are paused until you call `configure` again. Events tracked in between are dropped, never sent later. - Billing logout stays explicit. Until Billing's `logIn` or `logOut` runs, the Billing user who was current at reset gets no install device id, even after a relaunch (0.9.3+). - `reset()` is safe to call before `configure` and more than once. It doesn't change the collection setting. ## SKAdNetwork and AdAttributionKit Apple's own attribution works for every install, including users who don't allow tracking, but Apple only sends a postback once the app has set a conversion value. The SDK (0.7.0+) does this for you: - On first launch it registers the install (value 0, coarse `low`). - It raises the value from AppRefer's own data: **10** (`medium`) once a free trial starts, **20 + whole dollars paid** (`high`, capped at 63) once the user pays. Values only go up. - It checks at launch, when the app becomes active, after trial and revenue events and after AppRefer Billing purchases, for the 35 days Apple's postbacks cover. It updates AdAttributionKit (iOS 17.4+) and SKAdNetwork (iOS 15.4+; SKAdNetwork 3 on 15.0 to 15.3). If you take payments outside AppRefer Billing, call this after a purchase to update the value sooner: ```swift await AppRefer.updateConversionValue() ``` To get copies of the winning postbacks in AppRefer, add these keys to Info.plist, plus the `SKAdNetworkItems` identifiers your ad networks publish: **Info.plist** ```xml NSAdvertisingAttributionReportEndpoint https://apprefer.com AttributionCopyEndpoint https://apprefer.com ``` Postback reporting is described in [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md). ## AppRefer Billing `AppReferBilling` runs App Store subscriptions and one-time purchases on StoreKit 2. AppRefer verifies each signed transaction, keeps the customer's entitlements and sends the conversion to your ad networks from the same attribution, so no separate purchases SDK or webhook is needed. Billing must be turned on for the app in AppRefer first; see [Billing](https://apprefer.com/docs/billing.md) for the server setup and the move from RevenueCat. ### Set up ```swift Task { await AppRefer.configure(apiKey: "pk_live_...") } // Same SDK key. Starts the StoreKit transaction listener right away. AppReferBilling.configure( appUserId: nil, // nil = anonymous "$ARAnon:", kept in the Keychain entitlementsByProduct: [ "pro_monthly": ["premium"], "pro_yearly": ["premium"], ] ) ``` | Parameter | Default | Description | | --- | --- | --- | | `apiKey` | the configure key | SDK key, if different | | `appUserId` | nil | Your user id; `nil` starts anonymous | | `entitlementsByProduct` | \[:\] | Product id to entitlement ids, used only when AppRefer can't be reached | | `defaultEntitlement` | nil | Entitlement for unmapped products (the server default is `premium`) | | `debug` | `false` | Console logs | | `logLevel` | 1 | 0 to 3, as for configure | ### Offerings and paywalls Offerings let you change what a paywall sells from the dashboard (packages, placements, targeting and experiments) without hard-coding product ids. See [Offerings](https://apprefer.com/docs/offerings.md). ```swift let offerings = try await AppReferBilling.offerings() if let offering = offerings.currentOffering(forPlacement: "onboarding"), let annual = offering.annual { priceLabel.text = annual.storeProduct.displayPrice let result = try await AppReferBilling.purchase(package: annual) } ``` - `offerings.current` is the resolved default and `offerings.all` holds every offering by id. Each offering has `availablePackages`, `metadata`, `package(identifier:)` and the shortcuts `weekly`, `monthly`, `twoMonth`, `threeMonth`, `sixMonth`, `annual` and `lifetime`. - An unknown placement falls back to the current offering; a placement set to no offering returns `nil` (show no paywall). - Buying through `purchase(package:)` records the offering, placement and experiment with the purchase. - Offerings are cached for five minutes (memory and disk). Offline, the last copy is used; with no copy at all the call throws, so keep your existing product ids as a fallback. ### Products, purchase and restore ```swift let products = try await AppReferBilling.products(["pro_monthly", "pro_yearly"]) // StoreKit Products, in this order let result = try await AppReferBilling.purchase(products[1]) switch result.outcome { case .purchased: unlock(result.customerInfo) case .userCancelled: break case .pending: showAskToBuyNote() // arrives later through customerInfoStream } let info = try await AppReferBilling.restorePurchases() // AppStore.sync(), then sends every current entitlement ``` - `purchase` tags the transaction with the customer's AppRefer account token (`appAccountToken`), sends it to AppRefer and only then finishes it. - Signed promotional offers: `purchase(product, promotionalOffer: .init(offerId:keyId:nonce:signature:timestamp:))`. Extra StoreKit options go in `options:`. - Who is buying is saved before the sheet opens, so a purchase that completes later (Ask to Buy, a restart, a user switch) still goes to them. If that can't be saved, `purchase` throws `AppReferBillingError.purchaseIntentNotSaved` and the sheet doesn't open. - Only `restorePurchases()` can move a purchase owned by another app user to the one signed in. - `await AppReferBilling.rejectedPurchases()` lists purchases AppRefer refused for good, for your support team. ### Customer info and entitlements ```swift let info = await AppReferBilling.customerInfo() // cached for 5 minutes, then fetched let fresh = await AppReferBilling.customerInfo(fetchPolicy: .fetchCurrent) if info.entitlements["premium"]?.isActive == true { /* unlocked */ } for await info in AppReferBilling.customerInfoStream { update(info) } // or set AppReferBilling.delegate ``` | Field | Description | | --- | --- | | `entitlements` | By id; `.active` lists only active ones | | `activeSubscriptions` | Product ids of active subscriptions | | `hasActiveEntitlement` | True when any entitlement is active | | `appUserId` | The customer the answer is for | | `source` | `.server`, `.cache` or `.localFallback` | | `EntitlementInfo` | `isActive`, `productIdentifier`, `expirationDate`, `willRenew`, `periodType`, `store`, `state` | `customerInfo` never throws. If AppRefer can't be reached, entitlements come from StoreKit's verified current entitlements, mapped through `entitlementsByProduct`, so a paying user is never locked out. ### Users ```swift try await AppReferBilling.logIn("user_123") // moves purchases made while anonymous to this user try await AppReferBilling.logOut() // new anonymous id let id = await AppReferBilling.appUserId // "$ARAnon:..." while anonymous let anonymous = await AppReferBilling.isAnonymous await AppReferBilling.setEmail("a@example.com") // also setDisplayName, setPhoneNumber, setAttributes ``` The install's device id is sent with purchases automatically, so purchases are matched to the ad click without `setUserId`. It is left out while collection is off and after `reset()` until `configure` runs again. ### Sandbox and App Review - Test with a sandbox Apple ID. Sandbox purchases are recorded as sandbox, unlock the entitlement and are never forwarded to ad networks or counted as revenue. - App Review buys with sandbox accounts in your release build; those purchases work with the live key. - A real (production) purchase sent with a `pk_test_` key is refused. - Transactions from an Xcode StoreKit configuration file are signed with a local certificate AppRefer rejects. The SDK still unlocks them through its local fallback, but they never reach AppRefer. ## Support An in-app help chat your team answers from the AppRefer inbox, with screenshots, quick-help topics, reply notifications and a help centre. SwiftUI, iOS 15+, Liquid Glass on iOS 26, 35 languages. Server setup and the inbox are covered in [Support](https://apprefer.com/docs/support.md). `AppRefer.support` is main-actor isolated. ### Open the chat ```swift AppRefer.support.open() // help home AppRefer.support.open(conversationId: "cv_...") // one conversation AppRefer.support.compose("Problem with my subscription") // home with the message field filled in // Or embed it in your own navigation AppReferSupportView(opening: .home) // .helpCentre, .article("slug"), .conversation("id") ``` ### Identify and customise ```swift AppRefer.support.identify(name: "Alex", email: "alex@example.com") // the user id from setUserId is sent automatically AppRefer.support.accentColor = .purple // text on a bright accent turns dark AppRefer.support.title = "Sparkly support" // default " support", translated AppRefer.support.teamNames = ["Sam", "Ana"] // first names on the home AppRefer.support.replyTime = "under an hour" // completes "We usually reply in …" AppRefer.support.appIcon = UIImage(named: "HelpIcon") AppRefer.support.topics = [.billing, .bug, .idea, AppReferSupportTopic(title: "Restore purchase", subtitle: "Get your plan back", systemImage: "arrow.clockwise", tint: .blue, prompt: "", action: { Task { try? await AppReferBilling.restorePurchases() } })] AppRefer.support.language = "de" // force a language; nil follows the app AppRefer.support.strings = ["support.home.subtitle": "What can we fix for you?"] ``` Quick-help tiles default to `.billing`, `.restore`, `.bug` and `.idea`; an empty list hides them. A tile with an `action` runs it instead of starting a message. The chat follows the language your app runs in, then the device, then English. ### Reply notifications AppRefer sends reply notifications through APNs with your key, and emails a reply only if it stays unread for an hour. Register for push once `status().pushReady` is true. ```swift if (try? await AppRefer.support.status())?.pushReady == true { UIApplication.shared.registerForRemoteNotifications() } // AppDelegate func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) { AppRefer.support.registerDeviceToken(token) } // UNUserNotificationCenterDelegate, when the user taps a notification if AppRefer.support.handleNotification(response.notification.request.content.userInfo) { return } let unread = await AppRefer.support.unreadCount() // for a badge ``` **Push environment (0.10.1+).** The token is registered for the environment the build 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. Force one with `AppRefer.support.pushEnvironment = .sandbox` and read the one in use from `effectivePushEnvironment`. ### Help centre Published help articles appear in the chat with no code (0.10.0+): a "Help articles" card on the home, a searchable help centre and a reader. You can open them directly or build your own screens. ```swift AppRefer.support.openHelpCentre() AppRefer.support.open(article: "restore-purchases") let articles = try await AppRefer.support.articles() // [AppReferHelpArticle] let results = try await AppRefer.support.articles(query: "refund") // server search, up to 20 do { let article = try await AppRefer.support.article(slug: "refunds") // + body (markdown) } catch let error as AppReferSupportError where error.isNotFound { // removed or unpublished } ``` Articles come in the chat's language and are cached for five minutes; offline, the last copy is shown. Test keys also see drafts. ### Status ```swift let status = try await AppRefer.support.status() status.pushReady // APNs key verified for this build's environment status.emailReplies // customers can answer reply emails status.helpCentreArticles // number of published articles ``` `status()` follows the server's caching (five minutes once every channel is verified, otherwise 30 seconds), so calling it at every launch is cheap. ### Support without attribution Apps that want only the chat can start Support on its own. It doesn't configure attribution, collect device signals or send analytics. ```swift await AppRefer.support.configure(apiKey: "pk_live_...") // Forget the name, email and user id and start a fresh anonymous session await AppRefer.support.clearIdentity() ``` ## Requirements | Requirement | Minimum | | --- | --- | | iOS | 15.0 (macOS 12, tvOS 15) | | Swift | 5.10 (Xcode 15.3) | | Dependencies | None | ## Changelog highlights | Version | What changed | | --- | --- | | 0.13.1 | Narrow-screen Support text wrapping, measured composer spacing and keyboard anchoring. React Native Support-only apps bundle without the optional purchase package. | | 0.13.0 | Photo/video testimonials from Support, the app or an authenticated coach; usage terms accepted on Submit, signed uploads and receipt-based rewards. See the Support reference for integration. | | 0.12.0 | Verified customer context, resolution actions, offering and optional review cards, outcomes and tailored Support setup. | | 0.11.0 | Version parity release. Nothing on this page changes. | | 0.10.1 | Support push tokens use the build's signing environment instead of the DEBUG flag; new `support.pushEnvironment` and `effectivePushEnvironment`. | | 0.10.0 | Help centre in the chat; `support.articles`, `article(slug:)`, `status()`, `openHelpCentre()`, `open(article:)`. Chat in 35 languages with `language` and `strings` overrides. | | 0.9.3 | The Billing account gate set by `reset()` survives a relaunch. | | 0.9.2 | Billing sends no install device id while collection is off or after `reset()`; new `support.clearIdentity()`. | | 0.9.1 | Events carry `app_user_id`; `setCollectionEnabled`; after `reset()` call `configure` again. | | 0.9.0 | `reset()`, Support-only configure, durable event queue, `trackEventResult`, `clearPendingEvents`, `configureResult`. | | 0.8.0 | Offerings, placements and experiments; `purchase(package:)`. Products mapped to no entitlement unlock nothing offline. | | 0.7.0 | SKAdNetwork and AdAttributionKit conversion values; `updateConversionValue()`. | | 0.6.0 | Tracking consent split: `trk.apprefer.com` only for ATT-authorized installs; `trackingConsentDidChange()`, `trackingConsent()`. | Full history: the `CHANGELOG.md` in [apprefer-ios-sdk](https://github.com/AppAgentic/apprefer-ios-sdk). --- Source: https://apprefer.com/docs/sdk/ios · Last updated: 2026-10-20 > 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). # Android SDK Native Kotlin library for attribution, product events, AppRefer Billing (Google Play Billing) and the in-app Support chat, with Java-friendly callbacks. Every page in this section follows the same order, so you can switch between the [iOS](https://apprefer.com/docs/sdk/ios.md), [Android](https://apprefer.com/docs/sdk/android.md), [Flutter](https://apprefer.com/docs/sdk/flutter.md) and [React Native](https://apprefer.com/docs/sdk/react-native.md) references and find the same thing in the same place. All four SDKs share one version number; this page documents **0.13.1**. ## Install The SDK is published through JitPack. Add the repository once per project, then the dependency. **settings.gradle.kts** ```kotlin dependencyResolutionManagement { repositories { google() mavenCentral() maven("https://jitpack.io") } } ``` **app/build.gradle.kts** ```kotlin dependencies { // Attribution, events and Support only (minSdk 21, no Play Billing) implementation("com.github.AppAgentic.apprefer-android-sdk:apprefer-android-sdk:0.13.1") // Or attribution + AppRefer Billing (minSdk 23, adds Play Billing 8.3) implementation("com.github.AppAgentic.apprefer-android-sdk:apprefer-billing:0.13.1") } ``` The short coordinate `com.github.AppAgentic:apprefer-android-sdk:0.13.1` brings in both modules, including Play Billing and its `BILLING` permission. > **Pre-1.0 versions** > > Before 1.0, a minor version can change behaviour. Pin an exact version and read the changelog highlights below before upgrading. ### Platform setup - **Install Referrer** is a dependency of the SDK; you don't add it yourself. AppRefer reads the Play referrer on first launch (click ids such as `gclid` and `ar_click_id`, plus `utm_*`). - **Permissions.** The SDK declares `android.permission.INTERNET` and `com.google.android.gms.permission.AD_ID`. The Advertising ID is read only when tracking consent is granted (or not set). If your app shouldn't declare it, remove it in your manifest; attribution then works without it: **AndroidManifest.xml** ```xml ``` - The SDK picks up your application context on its own (through a small content provider), so calls made before `configure` that need a context still work. - For right-to-left languages in the Support chat, keep `android:supportsRtl="true"` on your ``. ## Configure Call `configure` once at launch. On the first launch it sends the install's signals and Play Install Referrer to AppRefer and resolves attribution; on later launches it returns the cached result. It is a suspend function and never throws. **App.kt** ```kotlin import com.apprefer.sdk.AppRefer class App : Application() { override fun onCreate() { super.onCreate() CoroutineScope(Dispatchers.IO).launch { val attribution = AppRefer.configure(this@App, apiKey = "pk_live_...") // attribution?.network, attribution?.matchType } } } ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `context` | Context | required | Any context; the application context is kept | | `apiKey` | String | required | SDK key (`pk_live_…` or `pk_test_…`) | | `userId` | String? | null | Your app's user id, if someone is already signed in | | `debug` | Boolean | false | Logcat logs | | `logLevel` | Int | 1 | 0 none, 1 errors, 2 warnings, 3 verbose | `configure` returns `Attribution?`. Methods called before it do nothing. ### Java Every suspend call has a callback overload taking `AppReferCallback`: ```java AppRefer.configure(this, "pk_live_...", null, false, 1, new AppReferCallback() { @Override public void onResult(Attribution result) { /* may be null */ } @Override public void onError(Throwable error) { } }); ``` > **Live and test keys** > > The server picks the environment from the key. `pk_test_` installs, events and purchases are sandbox data: visible in the dashboard's sandbox view and Debugger, never forwarded to ad networks. Use `pk_live_` in release builds. ### configureResult `configureResult` takes the same parameters and tells you why there is or isn't an attribution. It reports organic only after the server has said so, never because the network failed. ```kotlin when (val result = AppRefer.configureResult(context, apiKey = "pk_live_...")) { is AppReferAttributionResult.Attributed -> showWelcome(result.attribution.campaignName) AppReferAttributionResult.Pending -> Unit // decision held by the server AppReferAttributionResult.Organic -> Unit // the server answered: no ad click matched is AppReferAttributionResult.Failed -> Log.w("app", "no answer", result.error) } ``` | Result | Meaning | | --- | --- | | `Attributed(attribution)` | Matched to an ad click or a Play referrer | | `Pending` | The server is holding the decision while consent is undetermined (iOS installs, mostly) | | `Organic` | Confirmed organic install | | `Failed(error)` | No answer from the server and nothing cached | `AppRefer.getAttribution()` returns the cached attribution at any time without a network call. ## Attribution result The `Attribution` data class returned by `configure` and `getAttribution()`: | Property | Type | Description | | --- | --- | --- | | `network` | String | Ad network, e.g. `meta`, `google`, `tiktok`, `openai`, or `organic` | | `matchType` | String | How the install was matched, e.g. `install_referrer`, `install_referrer_gclid`, `ip_match`, `probabilistic`, `organic` (see [Attribution](https://apprefer.com/docs/attribution.md)) | | `attributionId` | String? | AppRefer's id for this attribution | | `campaignId` | String? | Campaign id from the ad network or tracking link | | `campaignName` | String? | Campaign name | | `adGroupId` | String? | Ad set / ad group id | | `adId` | String? | Ad id | | `keyword` | String? | Search keyword | | `fbclid` | String? | Meta click id, when matched to a Meta click | | `gclid` | String? | Google click id | | `ttclid` | String? | TikTok click id | | `campaign` | String? | Older field kept for compatibility; use `campaignName` | | `queryParams` | Map | Extra tracking-link parameters, when present | | `customData` | Map | Custom data, when present | | `trackingDecisionPending` | Boolean | True while the server waits for a consent decision | | `createdAt` | Long | When the attribution was made (epoch ms) | ## Tracking consent Android has no system tracking prompt, so you tell AppRefer what your app's own consent flow decided. The choice is saved across launches and may be set before `configure`. ```kotlin AppRefer.setTrackingConsent(true) // granted AppRefer.setTrackingConsent(false) // denied AppRefer.setTrackingConsent(null) // not set: legacy behaviour AppRefer.trackingConsentDidChange() // re-send after any other change ``` | Consent | Host | What AppRefer does | | --- | --- | --- | | `true` | `trk.apprefer.com` | Full matching, Advertising ID, advanced matching and forwarding to ad networks | | `false` | `apprefer.com` | First-party attribution, analytics, Billing and Support; no Advertising ID, no advanced matching, nothing forwarded | | `null` (default) | `trk.apprefer.com`, falling back to `apprefer.com` | Legacy behaviour: requests carry no consent value and are treated as allowed | Apps that need consent recorded before any tracking can ask AppRefer to treat a missing value as denied (the app setting `trackingConsentRequired`). See [Privacy](https://apprefer.com/docs/privacy.md). ## User identity Tell AppRefer who is signed in, so Support conversations and product events carry the account (`app_user_id`). Use the same id you pass to `AppReferBilling.logIn`. You can also pass it as `userId` to `configure`. ```kotlin AppRefer.setUserId(currentUser.id) // suspend AppReferBilling.logIn(currentUser.id) // if you use AppRefer Billing ``` ### Advanced matching Send hashed customer details to improve Meta match rates. Values are SHA-256 hashed on the device and dropped unless tracking consent is granted (or not set). Call it once after sign-up or sign-in. ```kotlin AppRefer.setAdvancedMatching( email = "user@example.com", phone = "+15551234567", firstName = "Jane", lastName = "Doe", dateOfBirth = "19900131", // YYYYMMDD ) ``` ### Device id `AppRefer.getDeviceId()` returns AppRefer's install id (`null` before `configure`). AppRefer Billing sends it with purchases automatically. If you take payments through RevenueCat instead, set it as the `appreferId` subscriber attribute (see [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md)). ## Events Track the in-app moments that matter to you (sign-up, onboarding finished, first workout). Don't track purchases here: AppRefer Billing records purchases, renewals and refunds and forwards them for you. Which events reach ad networks is covered in [Events](https://apprefer.com/docs/events.md). ```kotlin AppRefer.trackEvent("signup") AppRefer.trackEvent("tutorial_complete", properties = mapOf("step" to "final")) // Need to know what happened to it? val result = AppRefer.trackEventResult("level_up", properties = mapOf("level" to 3)) // AppReferEventResult.SENT, QUEUED or DROPPED ``` | Parameter | Type | Description | | --- | --- | --- | | `eventName` | String | Event name, e.g. `signup` | | `properties` | Map? | JSON-compatible metadata | | `revenue` | Double? | Amount for revenue events you track yourself | | `currency` | String? | ISO 4217 code, e.g. `USD` | ### Offline queue - Events are saved on the device before they are sent and keep one stable event id, so delivery is **at least once**. - The queue holds up to 1,000 events for up to seven days; the oldest are dropped first. - Network errors, HTTP 408, 429 and 5xx are retried with backoff (up to an hour, or longer if the server asks), at launch and when the app returns to the foreground. Other 4xx answers are final. - `AppRefer.clearPendingEvents()` cancels event requests and empties the queue, for example when the user withdraws analytics consent. ## Collection control `setCollectionEnabled(false)` stops attribution, consent reports and product events, cancels what is in flight and clears the event queue. The choice is saved across launches; if it can't be saved, collection still stops for this session. Billing and Support keep working, without the install's device id. ```kotlin AppRefer.setCollectionEnabled(false) // consent withdrawn (suspend; optional context) // Later, to resume: enable, then configure again AppRefer.setCollectionEnabled(true) AppRefer.configure(context, apiKey = "pk_live_...") ``` Enabling alone sends nothing. Calling it with `true` while collection is already on changes nothing, so it is safe to call from a consent tool on every launch. ## Account switching Call `reset()` when a user signs out, and wait for it before the next account signs in. It revokes the old Support session and its push token, clears the Support profile, history and drafts, the user id and queued events. The install's own attribution and device id stay. ```kotlin // Sign out AppReferBilling.logOut() // if you use AppRefer Billing AppRefer.reset() // Next account signs in AppRefer.configure(context, apiKey = "pk_live_...", userId = nextUser.id) AppReferBilling.logIn(nextUser.id) ``` - After `reset()`, attribution, user id updates, advanced matching and events are paused until you call `configure` again. Events tracked in between are dropped, never sent later. - Billing logout stays explicit. Until Billing's `logIn` or `logOut` runs, the Billing user who was current at reset gets no install device id, even after a relaunch (0.9.3+). - `reset()` is safe to call before `configure` and more than once. It doesn't change the collection setting. ## SKAdNetwork and AdAttributionKit SKAdNetwork and AdAttributionKit are Apple-only. `AppRefer.updateConversionValue()` exists on Android for API parity and does nothing. If you ship the same app on iOS, see the [iOS SDK](https://apprefer.com/docs/sdk/ios.md#skadnetwork-and-adattributionkit) and [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md). ## AppRefer Billing `AppReferBilling` (module `apprefer-billing`) runs Google Play subscriptions and one-time products on Play Billing Library 8. AppRefer verifies each purchase with Google, acknowledges it, keeps the customer's entitlements and sends the conversion to your ad networks from the same attribution, so no separate purchases SDK or webhook is needed. Billing must be turned on for the app in AppRefer first (package name, a Play service account and products); see [Billing](https://apprefer.com/docs/billing.md) for the server setup and the move from RevenueCat. ### Set up ```kotlin import com.apprefer.sdk.billing.AppReferBilling // In Application.onCreate, after starting AppRefer.configure AppReferBilling.configure( context = this, appUserId = null, // null = anonymous "$ARAnon:", kept on the device entitlementsByProduct = mapOf( "pro_monthly" to listOf("premium"), "pro_yearly" to listOf("premium"), ), consumableProductIds = setOf("coins_100"), // one-time products that can be bought again ) ``` | Parameter | Default | Description | | --- | --- | --- | | `context` | required | Any context | | `apiKey` | the configure key | SDK key, if different | | `appUserId` | null | Your user id; `null` starts anonymous | | `entitlementsByProduct` | empty | Product id to entitlement ids, used only when AppRefer can't be reached | | `defaultEntitlement` | null | Entitlement for unmapped products (the server default is `premium`) | | `consumableProductIds` | empty | One-time products consumed after AppRefer records them; others are treated as lifetime | | `debug` | `false` | Logcat logs | | `logLevel` | 1 | 0 to 3, as for configure | ### Offerings and paywalls Offerings let you change what a paywall sells from the dashboard (packages, placements, targeting and experiments) without hard-coding product ids. See [Offerings](https://apprefer.com/docs/offerings.md). ```kotlin val offerings = AppReferBilling.offerings() val annual = offerings.currentOffering(forPlacement = "onboarding")?.annual if (annual != null) { // annual.storeProduct is the Play ProductDetails; annual.offerToken the selected offer val result = AppReferBilling.purchase(activity, annual) } ``` - `offerings.current` is the resolved default and `offerings.all` holds every offering by id. Each offering has `availablePackages`, `metadata`, a package lookup and the shortcuts `weekly`, `monthly`, `twoMonth`, `threeMonth`, `sixMonth`, `annual` and `lifetime`. - A Play product written `subscription:basePlan` selects that base plan; a bare subscription picks the base plan matching the package type, otherwise the first. - An unknown placement falls back to the current offering; a placement set to no offering returns `null` (show no paywall). - Buying a package records the offering, placement and experiment with the purchase. - Offerings are cached for five minutes (memory and disk). Offline, the last copy is used; with no copy at all the call throws, so keep your existing product ids as a fallback. ### Products, purchase and restore ```kotlin val products = AppReferBilling.products(listOf("pro_monthly", "pro_yearly")) // Play ProductDetails, in this order val result = AppReferBilling.purchase(activity, products[1]) when (result.outcome) { AppReferBilling.PurchaseResult.Outcome.PURCHASED -> unlock(result.customerInfo) AppReferBilling.PurchaseResult.Outcome.USER_CANCELLED -> Unit AppReferBilling.PurchaseResult.Outcome.PENDING -> showPendingNote() // arrives later through customerInfoStream } val info = AppReferBilling.restorePurchases() // queryPurchasesAsync, then sends every active purchase ``` - `purchase` sets `obfuscatedAccountId` to the customer's AppRefer account token and sends the purchase to AppRefer, which verifies and acknowledges it. Consumables are consumed only after AppRefer has accepted them. - Subscription offers: pass `offerToken`. Without one the SDK picks a free-trial offer the user is eligible for, else the base plan (`AppReferBilling.defaultOfferToken(product)`). - Upgrades and downgrades: pass `oldProductId` and `replacementMode` (default `WITH_TIME_PRORATION`). - Pending payments (cash, bank transfer) return `PENDING`, grant nothing and are recorded once Google Play reports them paid. - Google Play errors throw `AppReferBillingException.Store(responseCode, debugMessage)`. If who is buying can't be saved before Play opens, `purchase` throws `AppReferBillingException.Storage` instead. - Only `restorePurchases()` can move a purchase owned by another app user to the one signed in. `AppReferBilling.rejectedPurchases()` lists purchases AppRefer refused for good, for your support team. ### Customer info and entitlements ```kotlin val info = AppReferBilling.customerInfo() // cached for 5 minutes, then fetched val fresh = AppReferBilling.customerInfo(AppReferBilling.FetchPolicy.FETCH_CURRENT) if (info.entitlements["premium"]?.isActive == true) { /* unlocked */ } AppReferBilling.customerInfoStream.collect { update(it) } // Flow, latest value first AppReferBilling.listener = AppReferBillingListener { update(it) } // or a listener, on the main thread ``` | Field | Description | | --- | --- | | `entitlements` | By id; `.active` lists only active ones | | `activeSubscriptions` | Product ids of active subscriptions | | `appUserId` | The customer the answer is for | | `source` | `SERVER`, `CACHE` or `LOCAL_FALLBACK` | | `EntitlementInfo` | `isActive`, `productIdentifier`, `expirationDate`, `willRenew`, `periodType`, `store` | `customerInfo` never throws. If AppRefer can't be reached, entitlements come from Google Play's own active purchases, mapped through `entitlementsByProduct`, so a paying user is never locked out. If the upload fails, the SDK acknowledges the purchase on the device as soon as its retry copy is saved, so Google never refunds it for being unacknowledged. ### Users ```kotlin AppReferBilling.logIn("user_123") // moves purchases made while anonymous to this user AppReferBilling.logOut() // new anonymous id val id = AppReferBilling.appUserId // "$ARAnon:..." while anonymous val anonymous = AppReferBilling.isAnonymous AppReferBilling.setEmail("a@example.com") // also setDisplayName, setPhoneNumber, setAttributes ``` The install's device id is sent with purchases automatically, so purchases are matched to the ad click without `setUserId`. It is left out while collection is off and after `reset()` until `configure` runs again. ### Sandbox and App Review - Test with a license tester account on an internal testing track. Those purchases are recorded as sandbox, unlock the entitlement and are never forwarded to ad networks or counted as revenue. - Store reviewers' test purchases work with the live key. - A real (production) purchase sent with a `pk_test_` key is refused. - Play Billing needs a device or emulator image with the Play Store. ## Support An in-app help chat your team answers from the AppRefer inbox, with screenshots, quick-help topics, reply notifications and a help centre. Native views, no extra runtime dependencies, 35 languages. Server setup and the inbox are covered in [Support](https://apprefer.com/docs/support.md). ### Open the chat ```kotlin AppRefer.support.open() // help home AppRefer.support.open("cv_...") // one conversation AppRefer.support.compose("Problem with my subscription") // home with the message field filled in // Context overloads: open(context, id), compose(context, text) // Or embed it in your own layout AppReferSupportView(context, destination = AppReferSupportDestination.Home) // HelpCentre, Article("slug"), Conversation("id") ``` When you embed `AppReferSupportView`, wire `onClose` and `onPickImages`, pass picked URIs to `addImages`, and forward `onResume`/`onPause` to `resume`/`pause`. The full-screen activity does this for you. ### Identify and customise ```kotlin AppRefer.support.identify(name = "Alex", email = "alex@example.com") // the user id is sent automatically AppRefer.support.accentColor = Color.rgb(120, 82, 255) // text on a bright accent turns dark AppRefer.support.title = "Sparkly support" // default " support", translated AppRefer.support.teamNames = listOf("Sam") // first names on the home AppRefer.support.replyTime = "a few hours" // completes "We usually reply in …" AppRefer.support.appIcon = drawable // default: the launcher icon AppRefer.support.topics = listOf( AppReferSupportTopic.billing, AppReferSupportTopic.restore.copy(action = { restorePurchases() }), AppReferSupportTopic("Report a bug", "Screenshots welcome", R.drawable.ic_bug, Color.RED, "What went wrong?"), ) AppRefer.support.language = "de" // force a language; null follows the app AppRefer.support.strings = mapOf("support.home.subtitle" to "What can we fix for you?") ``` Quick-help tiles default to `billing`, `restore`, `bug` and `idea`; an empty list hides them. A tile with an `action` runs it instead of starting a message. The chat follows the app's language (limited to the languages in your `localeConfig`, if you declare one), then the device, then English. You can also override a string the Android way, with a resource such as `apprefer_support_home_subtitle` in your own `values-xx`. ### Reply notifications AppRefer sends reply notifications through FCM with your service account, and emails a reply only if it stays unread for an hour. Your app owns Firebase Messaging and the notification permission prompt; the SDK adds no Firebase dependency. Register the token once `status().pushReady` is true. ```kotlin // After getting an FCM token (suspend calls) if (runCatching { AppRefer.support.status() }.getOrNull()?.pushReady == true) { AppRefer.support.registerPushToken(token) } AppRefer.support.removePushToken(token) // when unregistering this device // From your notification tap handler (not on background receipt) if (AppRefer.support.handleNotification(data)) return val unread = AppRefer.support.unreadCount() // for a badge ``` Replies arrive on the `apprefer_support` channel (the SDK creates it, named in the chat's language) with data `{type: "support_reply", conversationId}`. Call `registerPushToken` again when the token rotates. **Push environment (0.10.1).** FCM has no sandbox and production split, so there is nothing to set on Android. ### Help centre Published help articles appear in the chat with no code (0.10.0+): a "Help articles" card on the home, a searchable help centre and a reader. You can open them directly or build your own screens. ```kotlin AppRefer.support.openHelpCentre() AppRefer.support.openArticle("restore-purchases") val articles = AppRefer.support.articles() // List val results = AppRefer.support.articles("refund") // server search, up to 20 try { val article = AppRefer.support.article("refunds") // + body (markdown) } catch (e: AppReferSupportException) { if (e.isNotFound) { /* removed or unpublished */ } } ``` Articles come in the chat's language and are cached for five minutes; offline, the last copy is shown. Test keys also see drafts. ### Status ```kotlin val status = AppRefer.support.status() status.pushReady // FCM configured and Google accepted the service account status.emailReplies // customers can answer reply emails status.helpCentreArticles // number of published articles ``` `status()` follows the server's caching (five minutes once every channel is verified, otherwise 30 seconds), so calling it at every launch is cheap. ### Support without attribution Apps that want only the chat can start Support on its own. It doesn't configure attribution, collect device signals or send analytics. ```kotlin AppRefer.support.configure(context, apiKey = "pk_live_...") // Forget the name, email and user id and start a fresh anonymous session (suspend) AppRefer.support.clearIdentity() ``` ## Requirements | Requirement | Minimum | | --- | --- | | minSdk | 21 (Android 5.0); 23 with apprefer-billing | | compileSdk | 35 | | JDK | 17 | | Kotlin | 2.0; 2.1 with apprefer-billing | | Dependencies | kotlinx-coroutines, Install Referrer, play-services-ads-identifier; Play Billing 8.3 with apprefer-billing | ## Changelog highlights | Version | What changed | | --- | --- | | 0.13.1 | Narrow-screen Support text wrapping, measured composer spacing and keyboard anchoring. React Native Support-only apps bundle without the optional purchase package. | | 0.13.0 | Photo/video testimonials from Support, the app or an authenticated coach; usage terms accepted on Submit, signed uploads and receipt-based rewards. See the Support reference for integration. | | 0.12.0 | Verified customer context, resolution actions, offering and optional review cards, outcomes and tailored Support setup. | | 0.11.0 | Version parity release. Nothing on this page changes. | | 0.10.1 | Version parity with the iOS push-environment change; no Android behaviour change. | | 0.10.0 | Help centre in the chat; `support.articles`, `article`, `status()`, `openHelpCentre()`, `openArticle()`. Chat in 35 languages with `language` and `strings` overrides. No API 24+ collection calls on older devices. | | 0.9.3 | The Billing account gate set by `reset()` survives a relaunch. | | 0.9.2 | Billing sends no install device id while collection is off or after `reset()`; new `support.clearIdentity()`. | | 0.9.1 | Events carry `app_user_id`; `setCollectionEnabled`; after `reset()` call `configure` again. | | 0.9.0 | `reset()`, Support-only configure, durable event queue, `trackEventResult`, `clearPendingEvents`, `configureResult`. | | 0.8.0 | Offerings, placements and experiments; `purchase(activity, package)`. Products mapped to no entitlement unlock nothing offline. | | 0.7.0 | `updateConversionValue()` added as a no-op for parity with iOS. | | 0.6.0 | Tracking consent split: `setTrackingConsent`, `trackingConsentDidChange`; requests pick their host from consent. | Full history: the `CHANGELOG.md` in [apprefer-android-sdk](https://github.com/AppAgentic/apprefer-android-sdk). --- Source: https://apprefer.com/docs/sdk/android · Last updated: 2026-10-20 > 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). # Flutter SDK One Dart package for attribution, product events, SKAdNetwork and AdAttributionKit conversion values, AppRefer Billing and the in-app Support chat, on iOS and Android. Every page in this section follows the same order, so you can switch between the [iOS](https://apprefer.com/docs/sdk/ios.md), [Android](https://apprefer.com/docs/sdk/android.md), [Flutter](https://apprefer.com/docs/sdk/flutter.md) and [React Native](https://apprefer.com/docs/sdk/react-native.md) references and find the same thing in the same place. All four SDKs share one version number; this page documents **0.13.1**. ## Install Add the package from GitHub, pinned to the release tag, then run `flutter pub get`. **pubspec.yaml** ```yaml dependencies: apprefer: git: url: https://github.com/AppAgentic/apprefer-flutter-sdk.git ref: 0.13.1 ``` ```dart import 'package:apprefer/apprefer.dart'; ``` > **Pre-1.0 versions** > > Before 1.0, a minor version can change behaviour. Keep the `ref` pinned to a tag and read the changelog highlights below before upgrading. ### Platform setup - **iOS:** iOS 15 or later (run `pod install` after upgrading). In your privacy manifest (`PrivacyInfo.xcprivacy`), declare **only** `trk.apprefer.com` under `NSPrivacyTrackingDomains`, never `apprefer.com`. For postback copies, add the Info.plist keys in [SKAdNetwork and AdAttributionKit](#skadnetwork-and-adattributionkit). - **Android:** nothing to add. The plugin brings the Play Install Referrer and Advertising ID libraries and declares `INTERNET` and `com.google.android.gms.permission.AD_ID`. The Advertising ID is read only when tracking consent is granted (or not set); remove the permission with `tools:node="remove"` if your app shouldn't declare it. **ios/Runner/PrivacyInfo.xcprivacy** ```xml NSPrivacyTracking NSPrivacyTrackingDomains trk.apprefer.com ``` ## Configure Call `configure` once, as early as possible at launch. On the first launch it sends the install's signals to AppRefer (AdServices on iOS, Install Referrer on Android) and resolves attribution; on later launches it returns the cached result. Calling it again with the same config reuses the existing instance. **main.dart** ```dart import 'package:apprefer/apprefer.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final attribution = await AppReferSDK.configure( const AppReferConfig(apiKey: 'pk_live_...'), ); // attribution?.network, attribution?.matchType runApp(const MyApp()); } ``` | Field | Type | Default | Description | | --- | --- | --- | --- | | `apiKey` | String | required | SDK key (`pk_live_…` or `pk_test_…`) | | `userId` | String? | null | Your app's user id, if someone is already signed in | | `debug` | bool | false | Print SDK logs | | `logLevel` | int | 1 | 0 none, 1 errors, 2 warnings, 3 verbose | `configure` returns `Future` and never throws. Methods called before it do nothing. > **Live and test keys** > > The server picks the environment from the key. `pk_test_` installs, events and purchases are sandbox data: visible in the dashboard's sandbox view and Debugger, never forwarded to ad networks. Use `pk_live_` in release builds. ### configureResult `configureResult` takes the same config and tells you why there is or isn't an attribution. It reports organic only after the server has said so, never because the network failed. ```dart final result = await AppReferSDK.configureResult( const AppReferConfig(apiKey: 'pk_live_...'), ); switch (result.status) { case AttributionStatus.attributed: showWelcome(result.attribution!.campaignName); case AttributionStatus.pending: break; // waiting for the user's ATT answer (iOS) case AttributionStatus.organic: break; // the server answered: no ad click matched case AttributionStatus.failed: debugPrint('${result.error}'); } ``` | Status | Meaning | | --- | --- | | `attributed` | Matched; `result.attribution` is set | | `pending` | The server is holding the decision until the user answers the ATT prompt (up to 24 hours) | | `organic` | Confirmed organic install | | `failed` | No answer and nothing cached; `result.error` is set | `await AppReferSDK.getAttribution()` returns the cached attribution at any time without a network call. ## Attribution result The `Attribution` class returned by `configure` and `getAttribution()`: | Property | Type | Description | | --- | --- | --- | | `network` | String | Ad network, e.g. `meta`, `google`, `tiktok`, `apple_ads`, `openai`, or `organic` | | `matchType` | String | How the install was matched, e.g. `adservices`, `install_referrer`, `ip_match`, `probabilistic`, `organic` (see [Attribution](https://apprefer.com/docs/attribution.md)) | | `attributionId` | String? | AppRefer's id for this attribution | | `campaignId` | String? | Campaign id from the ad network or tracking link | | `campaignName` | String? | Campaign name | | `adGroupId` | String? | Ad set / ad group id | | `adId` | String? | Ad id | | `keyword` | String? | Search keyword | | `fbclid` | String? | Meta click id, when matched to a Meta click | | `gclid` | String? | Google click id | | `ttclid` | String? | TikTok click id | | `campaign` | String? | Older field kept for compatibility; use `campaignName` | | `queryParams` | Map | Extra tracking-link parameters, when present | | `customData` | Map | Custom data, when present | | `trackingDecisionPending` | bool | True while the server waits for the consent decision | | `createdAt` | DateTime | When the attribution was made | ## Tracking consent AppRefer keeps cross-company tracking and first-party work apart for each install. Granted consent uses `trk.apprefer.com` and allows matching to the ad click, the IDFA or Advertising ID, advanced matching and forwarding to ad networks. Denied or undetermined consent uses `apprefer.com` only: Apple Ads or organic attribution, analytics, Billing and Support, with nothing forwarded. Details: [Privacy](https://apprefer.com/docs/privacy.md). - **iOS:** the SDK reads the App Tracking Transparency status your app already has and **never shows the prompt**. If you show it, call `trackingConsentDidChange()` straight after. It also re-checks on every `configure` and whenever the app resumes. An undetermined install is held for up to 24 hours in case the user allows tracking. - **Android:** pass your app's own choice to `setTrackingConsent`. It is saved across launches; `null` (the default) keeps the legacy behaviour, where requests carry no consent value and are treated as allowed. ```dart // iOS: right after your own ATT prompt await AppReferSDK.trackingConsentDidChange(); // Android: your consent flow's answer (true, false, or null for legacy) await AppReferSDK.setTrackingConsent(false); ``` ## User identity Tell AppRefer who is signed in, so Support conversations and product events carry the account (`app_user_id`). Use the same id you pass to `AppReferBilling.logIn`. You can also pass it as `userId` in `AppReferConfig`. ```dart await AppReferSDK.setUserId(currentUser.id); await AppReferBilling.logIn(currentUser.id); // if you use AppRefer Billing ``` ### Advanced matching Send hashed customer details to improve Meta match rates. Values are SHA-256 hashed on the device and dropped unless tracking consent is granted (on Android, also when not set). Call it once after sign-up or sign-in. ```dart await AppReferSDK.setAdvancedMatching( email: 'user@example.com', phoneNumber: '+15551234567', firstName: 'Jane', lastName: 'Doe', dateOfBirth: '19900131', // YYYYMMDD ); ``` ### Device id `await AppReferSDK.getDeviceId()` returns AppRefer's install id. AppRefer Billing sends it with purchases automatically. If you take payments through RevenueCat instead, set it as the `appreferId` subscriber attribute (see [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md)). ## Events Track the in-app moments that matter to you (sign-up, onboarding finished, first workout). Don't track purchases here: AppRefer Billing records purchases, renewals and refunds and forwards them for you. Which events reach ad networks is covered in [Events](https://apprefer.com/docs/events.md). ```dart await AppReferSDK.trackEvent('signup'); await AppReferSDK.trackEvent('tutorial_complete', properties: {'step': 'final'}); // Need to know what happened to it? final result = await AppReferSDK.trackEventResult('level_up', properties: {'level': 3}); // AppReferEventResult.sent, .queued or .dropped ``` | Parameter | Type | Description | | --- | --- | --- | | `eventName` | String | Event name, e.g. `signup` | | `properties` | Map? | JSON-compatible metadata | | `revenue` | double? | Amount for revenue events you track yourself | | `currency` | String? | ISO 4217 code, e.g. `USD` | ### Offline queue - Events are saved on the device before they are sent and keep one stable event id, so delivery is **at least once**. - The queue holds up to 1,000 events for up to seven days; the oldest are dropped first. - Network errors, HTTP 408, 429 and 5xx are retried with backoff (up to an hour, or longer if the server asks), at launch and when the app resumes. Other 4xx answers are final. - `await AppReferSDK.clearPendingEvents()` cancels event requests and empties the queue, for example when the user withdraws analytics consent. ## Collection control `setCollectionEnabled(false)` stops attribution, consent reports, conversion values and product events, cancels what is in flight and clears the event queue. The choice is saved across launches; it never throws. Billing and Support keep working, without the install's device id. ```dart await AppReferSDK.setCollectionEnabled(false); // consent withdrawn // Later, to resume: enable, then configure again await AppReferSDK.setCollectionEnabled(true); await AppReferSDK.configure(const AppReferConfig(apiKey: 'pk_live_...')); ``` Enabling alone sends nothing. Calling it with `true` while collection is already on changes nothing, so it is safe to call from a consent tool on every launch. ## Account switching Call `reset()` when a user signs out, and await it before the next account signs in. It revokes the old Support session and its push token, clears the Support profile, history and drafts, the user id and queued events. The install's own attribution and device id stay. ```dart // Sign out await AppReferBilling.logOut(); // if you use AppRefer Billing await AppReferSDK.reset(); // Next account signs in await AppReferSDK.configure(AppReferConfig(apiKey: 'pk_live_...', userId: nextUser.id)); await AppReferBilling.logIn(nextUser.id); ``` - After `reset()`, attribution and events are paused until you call `configure` again. Events tracked in between are dropped, never sent later. - Billing logout stays explicit. Until Billing's `logIn` or `logOut` runs, the Billing user who was current at reset gets no install device id, even after a relaunch (0.9.3+). - `reset()` is safe to call before `configure` and more than once. It doesn't change the collection setting. ## SKAdNetwork and AdAttributionKit On iOS, the SDK (0.7.0+) sets Apple's conversion value for you, so SKAdNetwork and AdAttributionKit postbacks are sent even for users who don't allow tracking: - On first launch it registers the install (value 0, coarse `low`). - It raises the value from AppRefer's own data: **10** (`medium`) once a free trial starts, **20 + whole dollars paid** (`high`, capped at 63) once the user pays. Values only go up. - It checks at launch, when the app resumes, after trial and revenue events and after AppRefer Billing purchases, for 35 days. It updates AdAttributionKit (iOS 17.4+) and SKAdNetwork (iOS 15.4+). If you take payments outside AppRefer Billing, call this after a purchase. On Android it does nothing. ```dart await AppReferSDK.updateConversionValue(); ``` To get copies of the winning postbacks in AppRefer, add these keys to `ios/Runner/Info.plist`, plus the `SKAdNetworkItems` identifiers your ad networks publish. See [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md). **ios/Runner/Info.plist** ```xml NSAdvertisingAttributionReportEndpoint https://apprefer.com AttributionCopyEndpoint https://apprefer.com ``` ## AppRefer Billing `AppReferBilling` runs App Store and Google Play subscriptions and one-time purchases through the official `in_app_purchase` plugins (StoreKit 2 on iOS, Play Billing on Android). AppRefer verifies each purchase with Apple or Google, acknowledges Google purchases, keeps the customer's entitlements and sends the conversion to your ad networks from the same attribution. Billing must be turned on for the app in AppRefer first; see [Billing](https://apprefer.com/docs/billing.md) for the server setup and the move from RevenueCat. ### Set up ```dart AppReferSDK.configure(const AppReferConfig(apiKey: 'pk_live_...')); // Same SDK key. Starts the store purchase listener right away. await AppReferBilling.configure( appUserId: null, // null = anonymous "$ARAnon:" entitlementsByProduct: { 'pro_monthly': ['premium'], 'pro_yearly': ['premium'], }, consumableProductIds: {'coins_100'}, // Google Play consumables; iOS reads the type from StoreKit ); ``` | Parameter | Default | Description | | --- | --- | --- | | `apiKey` | the configure key | SDK key, if different | | `appUserId` | null | Your user id; `null` starts anonymous | | `entitlementsByProduct` | {} | Product id to entitlement ids, used only when AppRefer can't be reached | | `defaultEntitlement` | null | Entitlement for unmapped products (the server default is `premium`) | | `consumableProductIds` | {} | Google Play one-time products consumed after AppRefer records them | | `debug` | `false` | Logs | | `logLevel` | 1 | 0 to 3, as for configure | > **Let AppRefer complete purchases** > > Don't call `completePurchase` or `InAppPurchase.instance.restorePurchases()` yourself. AppRefer Billing completes each purchase once AppRefer has recorded it. Listening to `purchaseStream` for UI is fine. ### Offerings and paywalls Offerings let you change what a paywall sells from the dashboard (packages, placements, targeting and experiments) without hard-coding product ids. See [Offerings](https://apprefer.com/docs/offerings.md). ```dart final offerings = await AppReferBilling.offerings(); final annual = offerings.currentOffering('onboarding')?.annual; if (annual != null) { priceText = annual.storeProduct.price; final result = await AppReferBilling.purchasePackage(annual); } ``` - `offerings.current` is the resolved default and `offerings.all` holds every offering by id. Each offering has `availablePackages`, `metadata`, a package lookup and the shortcuts `weekly`, `monthly`, `twoMonth`, `threeMonth`, `sixMonth`, `annual` and `lifetime`. - An unknown placement falls back to the current offering; a placement set to no offering returns `null` (show no paywall). - Buying through `purchasePackage` records the offering, placement and experiment with the purchase. - Offerings are cached for five minutes (memory and disk). Offline, the last copy is used; with no copy at all the call throws, so keep your existing product ids as a fallback. ### Products, purchase and restore ```dart final products = await AppReferBilling.products(['pro_monthly', 'pro_yearly']); // ProductDetails, in this order final result = await AppReferBilling.purchase(products[1]); switch (result.outcome) { case PurchaseOutcome.purchased: unlock(result.customerInfo); case PurchaseOutcome.userCancelled: break; case PurchaseOutcome.pending: showPendingNote(); // arrives later through customerInfoStream } final info = await AppReferBilling.restorePurchases(); // AppStore.sync() on iOS, then sends every owned purchase ``` - `purchase` tags the purchase with the customer's AppRefer account token (Apple `appAccountToken`, Google `obfuscatedAccountId`), sends it to AppRefer and only then completes it. On Google Play a subscription returns one `ProductDetails` per base plan or offer. - Offers: `promotionalOffer: SK2PromotionalOffer(...)` or `winBackOfferId:` on iOS; `changeSubscriptionParam: ChangeSubscriptionParam(...)` for Google upgrades and downgrades. - Who is buying is saved before the sheet opens, so a purchase that completes later (Ask to Buy, a restart, a user switch) still goes to them. If that can't be saved, `purchase` throws a storage error and the sheet doesn't open. - Only `restorePurchases()` can move a purchase owned by another app user to the one signed in. `AppReferBilling.rejectedPurchases()` lists purchases AppRefer refused for good, for your support team. ### Customer info and entitlements ```dart final info = await AppReferBilling.customerInfo(); // cached for 5 minutes, then fetched final fresh = await AppReferBilling.customerInfo(fetchPolicy: FetchPolicy.fetchCurrent); if (info.entitlements['premium']?.isActive == true) { /* unlocked */ } AppReferBilling.customerInfoStream.listen(update); // replays the latest value AppReferBilling.addCustomerInfoUpdateListener(update); // or a listener ``` | Field | Description | | --- | --- | | `entitlements` | By id; `.active` lists only active ones | | `activeSubscriptions` | Product ids of active subscriptions | | `appUserId` | The customer the answer is for | | `source` | `CustomerInfoSource.server`, `.cache` or `.localFallback` | | `EntitlementInfo` | `isActive`, `productIdentifier`, `expirationDate` (`DateTime?`), `willRenew`, `periodType`, `store` | `customerInfo` never throws. If AppRefer can't be reached, entitlements come from the verified store purchases on the device, mapped through `entitlementsByProduct`, so a paying user is never locked out. ### Users ```dart await AppReferBilling.logIn('user_123'); // moves purchases made while anonymous to this user await AppReferBilling.logOut(); // new anonymous id final id = await AppReferBilling.appUserId; // "$ARAnon:..." while anonymous final anonymous = await AppReferBilling.isAnonymous; await AppReferBilling.setEmail('a@example.com'); // also setDisplayName, setPhoneNumber, setAttributes ``` The install's device id is sent with purchases automatically, so purchases are matched to the ad click without `setUserId`. It is left out while collection is off and after `reset()` until `configure` runs again. The anonymous id is kept in the app's preferences, so a reinstall starts a new one; a restore still finds the purchases. ### Sandbox and App Review - Test with a sandbox Apple ID or a Play license tester. Those purchases are recorded as sandbox, unlock the entitlement and are never forwarded to ad networks or counted as revenue. - App Review's sandbox purchases in your release build work with the live key. - A real (production) purchase sent with a `pk_test_` key is refused. - Transactions from an Xcode StoreKit configuration file are signed with a local certificate AppRefer rejects; the SDK still unlocks them locally, but they never reach AppRefer. ## Support An in-app help chat your team answers from the AppRefer inbox, with screenshots, quick-help topics, reply notifications and a help centre. Flutter widgets, 35 languages, no `localizationsDelegates` needed. Server setup and the inbox are covered in [Support](https://apprefer.com/docs/support.md). ### Open the chat ```dart await AppReferSDK.support.open(context: context); // help home await AppReferSDK.support.open(context: context, conversationId: 'cv_...'); await AppReferSDK.support.compose('Problem with my subscription', context: context); // Without a BuildContext (e.g. from a notification), give the SDK your navigator key AppReferSDK.support.navigatorKey = navigatorKey; // Or embed it in your own route const AppReferSupportView(); const AppReferSupportView(opening: AppReferSupportDestination.helpCentre); // AppReferSupportDestination.article('slug'), .conversation('id'), .home ``` ### Identify and customise ```dart await AppReferSDK.support.identify(name: 'Alex', email: 'alex@example.com'); // user id is sent automatically AppReferSDK.support.accentColor = Colors.deepPurple; // text on a bright accent turns dark AppReferSDK.support.title = 'Sparkly support'; // default " support", translated AppReferSDK.support.teamNames = ['Sam']; // first names on the home AppReferSDK.support.replyTime = 'a few hours'; // completes "We usually reply in …" AppReferSDK.support.appIcon = const AssetImage('assets/icon.png'); AppReferSDK.support.topics = [ AppReferSupportTopic.billing, AppReferSupportTopic( title: 'Restore purchase', subtitle: 'Get your plan back', icon: Icons.refresh_rounded, tint: Colors.blue, prompt: 'Which plan did you buy?', action: () => AppReferBilling.restorePurchases(), // runs instead of starting a message ), AppReferSupportTopic.bug, ]; AppReferSDK.support.language = 'de'; // force a language; null follows the app AppReferSDK.support.strings = {'support.home.subtitle': 'What can we fix for you?'}; ``` Quick-help tiles default to `billing`, `restore`, `bug` and `idea`; an empty list hides them. The chat follows the locale of the widget tree it opens in, then the device, then English. ### Reply notifications AppRefer sends reply notifications itself: through APNs with your key on iOS and FCM with your service account on Android. It emails a reply only if it stays unread for an hour. Your app owns the notification permission and messaging setup. Register the token once `status().pushReady` is true: the APNs device token (hex) on iOS, the FCM token on Android. ```dart final status = await AppReferSDK.support.status(); if (status.pushReady) await AppReferSDK.support.registerPushToken(token); // From a notification tap (not every foreground notification) if (AppReferSDK.support.handleNotification(message.data)) return; // When unregistering this device await AppReferSDK.support.removePushToken(token); final unread = await AppReferSDK.support.unreadCount(); // for a badge ``` On Android, replies arrive on the `apprefer_support` channel. **Push environment (0.10.1+).** On iOS the token is registered for the environment the build is signed for, read from the embedded 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. Force one with `AppReferSDK.support.pushEnvironment = 'sandbox'` or `registerPushToken(token, environment: 'sandbox')`. Android always uses production. ### Help centre Published help articles appear in the chat with no code (0.10.0+): a "Help articles" card on the home, a searchable help centre and a reader. You can open them directly or build your own screens. ```dart await AppReferSDK.support.openHelpCentre(context: context); await AppReferSDK.support.openArticle('restore-purchases', context: context); final articles = await AppReferSDK.support.articles(); // List final results = await AppReferSDK.support.articles(query: 'refund'); // server search, up to 20 try { final article = await AppReferSDK.support.article('refunds'); // + body (markdown) } on SupportException catch (e) { if (e.status == 404) { /* removed or unpublished */ } } ``` Articles come in the chat's language and are cached for five minutes; offline, the last copy is shown. Test keys also see drafts. ### Status ```dart final status = await AppReferSDK.support.status(); status.pushReady; // this platform's push verified (on iOS, for this build's environment) status.emailReplies; // customers can answer reply emails status.helpCentreArticles; // number of published articles ``` `status()` follows the server's caching (five minutes once every channel is verified, otherwise 30 seconds), so calling it at every launch is cheap. ### Support without attribution Apps that want only the chat can start Support on its own. It doesn't configure attribution, collect device signals or send analytics. ```dart AppReferSDK.support.configure(apiKey: 'pk_live_...'); // Forget the name, email and user id and start a fresh anonymous session await AppReferSDK.support.clearIdentity(); ``` ## Requirements | Requirement | Minimum | | --- | --- | | Flutter | 3.41 | | Dart | 3.11 | | iOS | 15.0 | | Android | API 21 | | Dependencies | shared\_preferences, http, crypto, device\_info\_plus, package\_info\_plus, intl, in\_app\_purchase (with the StoreKit and Android plugins) | ## Changelog highlights | Version | What changed | | --- | --- | | 0.13.1 | Narrow-screen Support text wrapping, measured composer spacing and keyboard anchoring. React Native Support-only apps bundle without the optional purchase package. | | 0.13.0 | Photo/video testimonials from Support, the app or an authenticated coach; usage terms accepted on Submit, signed uploads and receipt-based rewards. See the Support reference for integration. | | 0.12.0 | Verified customer context, resolution actions, offering and optional review cards, outcomes and tailored Support setup. | | 0.11.0 | Version parity release. Nothing on this page changes. | | 0.10.1 | iOS push tokens use the build's signing environment; new `support.pushEnvironment` and the `environment` argument on `registerPushToken`. | | 0.10.0 | Help centre in the chat; `support.articles`, `article`, `status()`, `openHelpCentre()`, `openArticle()`. Chat in 35 languages with `language` and `strings` overrides (adds the `intl` dependency). | | 0.9.3 | The Billing account gate set by `reset()` survives a relaunch. | | 0.9.2 | Billing sends no install device id while collection is off or after `reset()`; new `support.clearIdentity()`. | | 0.9.1 | Events carry `app_user_id`; `setCollectionEnabled`; after `reset()` call `configure` again. | | 0.9.0 | `reset()`, Support-only configure, durable event queue, `trackEventResult`, `clearPendingEvents`, `configureResult`. | | 0.8.0 | Offerings, placements and experiments; `purchasePackage`. Products mapped to no entitlement unlock nothing offline. | | 0.7.0 | SKAdNetwork and AdAttributionKit conversion values on iOS; `updateConversionValue()`. | | 0.6.0 | Tracking consent split: `trackingConsentDidChange()` (iOS) and `setTrackingConsent` (Android); requests pick their host from consent. | Full history: the `CHANGELOG.md` in [apprefer-flutter-sdk](https://github.com/AppAgentic/apprefer-flutter-sdk). --- Source: https://apprefer.com/docs/sdk/flutter · Last updated: 2026-10-20 > 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). # React Native SDK TypeScript SDK for attribution, product events, SKAdNetwork and AdAttributionKit conversion values, AppRefer Billing and the in-app Support chat, on iOS and Android. Every page in this section follows the same order, so you can switch between the [iOS](https://apprefer.com/docs/sdk/ios.md), [Android](https://apprefer.com/docs/sdk/android.md), [Flutter](https://apprefer.com/docs/sdk/flutter.md) and [React Native](https://apprefer.com/docs/sdk/react-native.md) references and find the same thing in the same place. All four SDKs share one version number; this page documents **0.13.1**. ## Install Install the tagged GitHub release (the package isn't on the npm registry) together with its AsyncStorage peer, then install the pods. Git installs build the JavaScript and type declarations automatically. ```bash npm install github:AppAgentic/apprefer-react-native-sdk#0.13.1 @react-native-async-storage/async-storage cd ios && pod install ``` ```typescript import { AppRefer } from '@apprefer/react-native-sdk'; import { AppReferBilling } from '@apprefer/react-native-sdk/billing'; // only if you use AppRefer Billing ``` > **Pre-1.0 versions** > > Before 1.0, a minor version can change behaviour. Keep the install pinned to a tag, read the changelog highlights below, and run `pod install` and rebuild the native app after every upgrade. ### Platform setup - The SDK ships native modules (the `AppRefer` pod on iOS and an autolinked Android package) for the ATT reader, AdServices, the Install Referrer, push environment and the Support chat. Expo apps need a development build, not Expo Go. - **iOS:** iOS 15 or later. In your privacy manifest (`PrivacyInfo.xcprivacy`), declare **only** `trk.apprefer.com` under `NSPrivacyTrackingDomains`, never `apprefer.com`. For postback copies, add the Info.plist keys in [SKAdNetwork and AdAttributionKit](#skadnetwork-and-adattributionkit). - **Android:** minimum API 23 (for the Support module). The SDK declares only `INTERNET` and never reads the Advertising ID, so it adds no `AD_ID` permission. **ios/YourApp/PrivacyInfo.xcprivacy** ```xml NSPrivacyTracking NSPrivacyTrackingDomains trk.apprefer.com ``` ## Configure Call `configure` once, as early as possible (your root layout or `App` component). On the first launch it sends the install's signals to AppRefer and resolves attribution; on later launches it returns the cached result. Repeat calls reuse the existing instance. **App.tsx** ```typescript import { AppRefer } from '@apprefer/react-native-sdk'; useEffect(() => { AppRefer.configure({ apiKey: 'pk_live_...', debug: __DEV__ }).then((attribution) => { // attribution?.network, attribution?.matchType }); }, []); ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `apiKey` | string | required | SDK key (`pk_live_…` or `pk_test_…`) | | `userId` | string | undefined | Your app's user id, if someone is already signed in | | `debug` | boolean | false | Console logs | | `logLevel` | number | 1 | 0 none, 1 errors, 2 warnings, 3 verbose | `configure` resolves to `Attribution | null` and never rejects. Methods called before it do nothing. > **Live and test keys** > > The server picks the environment from the key. `pk_test_` installs, events and purchases are sandbox data: visible in the dashboard's sandbox view and Debugger, never forwarded to ad networks. Use `pk_live_` in release builds. ### configureResult `configureResult` takes the same options and tells you why there is or isn't an attribution. It reports organic only after the server has said so, never because the network failed. ```typescript const result = await AppRefer.configureResult({ apiKey: 'pk_live_...' }); switch (result.status) { case 'attributed': showWelcome(result.attribution.campaignName); break; case 'pending': break; // waiting for the user's ATT answer (iOS) case 'organic': break; // the server answered: no ad click matched case 'failed': console.warn(result.error); break; } ``` | Status | Meaning | | --- | --- | | `'attributed'` | Matched; `result.attribution` is set | | `'pending'` | The server is holding the decision until the user answers the ATT prompt (up to 24 hours) | | `'organic'` | Confirmed organic install | | `'failed'` | No answer and nothing cached; `result.error` is set | `AppRefer.getAttribution()` returns the cached attribution synchronously, without a network call. ## Attribution result The `Attribution` object returned by `configure` and `getAttribution()`: | Property | Type | Description | | --- | --- | --- | | `network` | string | Ad network, e.g. `meta`, `google`, `tiktok`, `apple_ads`, `openai`, or `organic` | | `matchType` | string | How the install was matched, e.g. `adservices`, `install_referrer`, `ip_match`, `probabilistic`, `organic` (see [Attribution](https://apprefer.com/docs/attribution.md)) | | `attributionId` | string? | AppRefer's id for this attribution | | `campaignId` | string? | Campaign id from the ad network or tracking link | | `campaignName` | string? | Campaign name | | `adGroupId` | string? | Ad set / ad group id | | `adId` | string? | Ad id | | `keyword` | string? | Search keyword | | `fbclid` | string? | Meta click id, when matched to a Meta click | | `gclid` | string? | Google click id | | `ttclid` | string? | TikTok click id | | `campaign` | string? | Older field kept for compatibility; use `campaignName` | | `trackingDecisionPending` | boolean? | True while the server waits for the consent decision | ### Deep links The SDK reads `ar_click_id` from the launch URL on its own. For links that open an already running app, extract it yourself: ```typescript import { AppReferDeepLinks } from '@apprefer/react-native-sdk'; const clickId = AppReferDeepLinks.extractClickId(url); ``` ## Tracking consent AppRefer keeps cross-company tracking and first-party work apart for each install. Granted consent uses `trk.apprefer.com` and allows matching to the ad click, the IDFA, advanced matching and forwarding to ad networks. Denied or undetermined consent uses `apprefer.com` only: Apple Ads or organic attribution, analytics, Billing and Support, with nothing forwarded. Details: [Privacy](https://apprefer.com/docs/privacy.md). - **iOS:** the SDK reads the App Tracking Transparency status your app already has and **never shows the prompt**. If you show it, call `trackingConsentDidChange()` straight after. It also re-checks on every `configure` and whenever the app becomes active. An undetermined install is held for up to 24 hours in case the user allows tracking. - **Android:** pass your app's own choice to `setTrackingConsent`. It is saved across launches; `null` (the default) keeps the legacy behaviour, where requests carry no consent value and are treated as allowed. ```typescript // iOS: right after your own ATT prompt await AppRefer.trackingConsentDidChange(); // Android: your consent flow's answer (true, false, or null for legacy) await AppRefer.setTrackingConsent(false); ``` ## User identity Tell AppRefer who is signed in, so Support conversations and product events carry the account (`app_user_id`). Use the same id you pass to `AppReferBilling.logIn`. You can also pass it as `userId` to `configure`. ```typescript await AppRefer.setUserId(currentUser.id); await AppReferBilling.logIn(currentUser.id); // if you use AppRefer Billing ``` ### Advanced matching Send hashed customer details to improve Meta match rates. Values are SHA-256 hashed on the device and dropped unless tracking consent is granted (on Android, also when not set). Call it once after sign-up or sign-in. ```typescript await AppRefer.setAdvancedMatching({ email: 'user@example.com', phone: '+15551234567', firstName: 'Jane', lastName: 'Doe', dateOfBirth: '19900131', // YYYYMMDD }); ``` ### Device id `await AppRefer.getDeviceId()` returns AppRefer's install id. AppRefer Billing sends it with purchases automatically. If you take payments through RevenueCat instead, set it as the `appreferId` subscriber attribute (see [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md)). ## Events Track the in-app moments that matter to you (sign-up, onboarding finished, first workout). Don't track purchases here: AppRefer Billing records purchases, renewals and refunds and forwards them for you. Which events reach ad networks is covered in [Events](https://apprefer.com/docs/events.md). ```typescript await AppRefer.trackEvent('signup'); await AppRefer.trackEvent('tutorial_complete', { properties: { step: 'final' } }); // Need to know what happened to it? const result = await AppRefer.trackEventResult('level_up', { properties: { level: 3 } }); // 'sent', 'queued' or 'dropped' ``` | Argument | Type | Description | | --- | --- | --- | | `eventName` | string | Event name, e.g. `signup` | | `options.properties` | Record | JSON-compatible metadata | | `options.revenue` | number | Amount for revenue events you track yourself | | `options.currency` | string | ISO 4217 code, e.g. `USD` | ### Offline queue - Events are saved in AsyncStorage before they are sent and keep one stable event id, so delivery is **at least once**. - The queue holds up to 1,000 events for up to seven days; the oldest are dropped first. - Network errors, HTTP 408, 429 and 5xx are retried with backoff (up to an hour, or longer if the server asks), at launch and when the app returns to the foreground. Other 4xx answers are final. - `await AppRefer.clearPendingEvents()` cancels event requests and empties the queue, for example when the user withdraws analytics consent. ## Collection control `setCollectionEnabled(false)` stops attribution, consent reports, conversion values and product events, cancels what is in flight and clears the event queue. The choice is saved across launches; it never throws. Billing and Support keep working, without the install's device id. ```typescript await AppRefer.setCollectionEnabled(false); // consent withdrawn // Later, to resume: enable, then configure again await AppRefer.setCollectionEnabled(true); await AppRefer.configure({ apiKey: 'pk_live_...' }); ``` Enabling alone sends nothing. Calling it with `true` while collection is already on changes nothing, so it is safe to call from a consent tool on every launch. ## Account switching Call `reset()` when a user signs out, and await it before the next account signs in. It revokes the old Support session and its push token, clears the Support profile, history and drafts, the user id and queued events. The install's own attribution and device id stay. ```typescript // Sign out await AppReferBilling.logOut(); // if you use AppRefer Billing await AppRefer.reset(); // Next account signs in await AppRefer.configure({ apiKey: 'pk_live_...', userId: nextUser.id }); await AppReferBilling.logIn(nextUser.id); ``` - After `reset()`, attribution and events are paused until you call `configure` again. Events tracked in between are dropped, never sent later. - Billing logout stays explicit. Until Billing's `logIn` or `logOut` runs, the Billing user who was current at reset gets no install device id, even after a relaunch (0.9.3+). - `reset()` is safe to call before `configure` and more than once. It doesn't change the collection setting. ## SKAdNetwork and AdAttributionKit On iOS, the SDK (0.7.0+) sets Apple's conversion value for you, so SKAdNetwork and AdAttributionKit postbacks are sent even for users who don't allow tracking: - On first launch it registers the install (value 0, coarse `low`). - It raises the value from AppRefer's own data: **10** (`medium`) once a free trial starts, **20 + whole dollars paid** (`high`, capped at 63) once the user pays. Values only go up. - It checks at launch, when the app becomes active, after trial and revenue events and after AppRefer Billing purchases, for 35 days. It updates AdAttributionKit (iOS 17.4+) and SKAdNetwork (iOS 15.4+). If you take payments outside AppRefer Billing, call this after a purchase. On Android it does nothing. ```typescript await AppRefer.updateConversionValue(); ``` To get copies of the winning postbacks in AppRefer, add these keys to your iOS `Info.plist`, plus the `SKAdNetworkItems` identifiers your ad networks publish. See [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md). **ios/YourApp/Info.plist** ```xml NSAdvertisingAttributionReportEndpoint https://apprefer.com AttributionCopyEndpoint https://apprefer.com ``` ## AppRefer Billing `AppReferBilling` runs App Store and Google Play subscriptions and one-time purchases through `react-native-iap` (StoreKit 2 on iOS, Play Billing on Android). AppRefer verifies each purchase with Apple or Google, acknowledges Google purchases, keeps the customer's entitlements and sends the conversion to your ad networks from the same attribution. It lives in its own entry point, `@apprefer/react-native-sdk/billing`, so attribution-only apps don't need `react-native-iap`. Billing must be turned on for the app in AppRefer first; see [Billing](https://apprefer.com/docs/billing.md) for the server setup and the move from RevenueCat. ### Set up ```bash npm install react-native-iap react-native-nitro-modules cd ios && pod install ``` ```typescript import { AppRefer } from '@apprefer/react-native-sdk'; import { AppReferBilling } from '@apprefer/react-native-sdk/billing'; AppRefer.configure({ apiKey: 'pk_live_...' }); // Same SDK key. Starts the store purchase listener right away. await AppReferBilling.configure({ appUserId: null, // null = anonymous "$ARAnon:" entitlementsByProduct: { pro_monthly: ['premium'], pro_yearly: ['premium'], }, consumableProductIds: ['coins_100'], // Google Play consumables; iOS reads the type from StoreKit }); ``` | Option | Default | Description | | --- | --- | --- | | `apiKey` | the configure key | SDK key, if different | | `appUserId` | null | Your user id; `null` starts anonymous | | `entitlementsByProduct` | {} | Product id to entitlement ids, used only when AppRefer can't be reached | | `defaultEntitlement` | undefined | Entitlement for unmapped products (the server default is `premium`) | | `consumableProductIds` | \[\] | Google Play one-time products consumed after AppRefer records them | > **Let AppRefer complete purchases** > > Don't call `finishTransaction`, `acknowledgePurchaseAndroid` or `consumePurchaseAndroid` yourself, and don't handle purchases in your own `purchaseUpdatedListener`. AppRefer Billing completes each purchase once AppRefer has recorded it. Listening for UI is fine. `react-native-iap` 17+ is a native module, so Expo apps need a development build. ### Offerings and paywalls Offerings let you change what a paywall sells from the dashboard (packages, placements, targeting and experiments) without hard-coding product ids. See [Offerings](https://apprefer.com/docs/offerings.md). ```typescript const offerings = await AppReferBilling.offerings(); const annual = offerings.currentOffering('onboarding')?.annual; if (annual) { setPrice(annual.storeProduct.displayPrice); const result = await AppReferBilling.purchasePackage(annual); } ``` - `offerings.current` is the resolved default and `offerings.all` holds every offering by id. Each offering has `availablePackages`, `metadata`, a package lookup and the shortcuts `weekly`, `monthly`, `twoMonth`, `threeMonth`, `sixMonth`, `annual` and `lifetime`. - An unknown placement falls back to the current offering; a placement set to no offering returns `null` (show no paywall). - Buying through `purchasePackage` records the offering, placement and experiment with the purchase. - Offerings are cached for five minutes (memory and AsyncStorage). Offline, the last copy is used; with no copy at all the call rejects, so keep your existing product ids as a fallback. ### Products, purchase and restore ```typescript const products = await AppReferBilling.products(['pro_monthly', 'pro_yearly']); // react-native-iap products, in this order const result = await AppReferBilling.purchase(products[1]); if (result.userCancelled) return; if (result.outcome === 'pending') showPendingNote(); // Ask to Buy / slow payment: arrives later if (result.customerInfo.entitlements.active.premium) unlock(); const info = await AppReferBilling.restorePurchases(); // AppStore.sync() on iOS, then sends every owned purchase ``` - `purchase` tags the purchase with the customer's AppRefer account token (Apple `appAccountToken`, Google `obfuscatedAccountId`), sends it to AppRefer and only then completes it. - Google Play options: `offerToken` (default: a free-trial offer the user is eligible for, else the base plan; see `defaultOfferToken(product)`), and `oldProductId` with `replacementMode` (default `with-time-proration`) for upgrades and downgrades. - App Store options: `promotionalOffer` and `winBackOfferId`. - Who is buying is saved before the sheet opens, so a purchase that completes later still goes to them. If that can't be saved, `purchase` rejects with a `storage` error and the sheet doesn't open. - Only `restorePurchases()` can move a purchase owned by another app user to the one signed in. `AppReferBilling.rejectedPurchases()` lists purchases AppRefer refused for good, for your support team. ### Customer info and entitlements ```typescript const info = await AppReferBilling.customerInfo(); // cached for 5 minutes, then fetched const fresh = await AppReferBilling.customerInfo({ fetchPolicy: 'fetchCurrent' }); if (info.entitlements.active.premium) { /* unlocked */ } info.entitlements.all.premium?.expirationDate; // ISO 8601, null for lifetime const remove = AppReferBilling.addCustomerInfoUpdateListener(update); remove(); // or AppReferBilling.removeCustomerInfoUpdateListener(update) ``` | Field | Description | | --- | --- | | `entitlements` | `.all` and `.active`, by id | | `activeSubscriptions` | Product ids of active subscriptions | | `appUserId` | The customer the answer is for | | `source` | `'server'`, `'cache'` or `'localFallback'` | | `EntitlementInfo` | `isActive`, `productIdentifier`, `expirationDate` (ISO string) and `expirationDateMillis`, `willRenew`, `periodType` (`normal`, `trial`, `intro`, `promotional`) | `customerInfo` never rejects, and the objects are plain JSON, safe to keep in React state. If AppRefer can't be reached, entitlements come from the verified store purchases on the device, mapped through `entitlementsByProduct`, so a paying user is never locked out. ### Users ```typescript await AppReferBilling.logIn('user_123'); // moves purchases made while anonymous to this user await AppReferBilling.logOut(); // new anonymous id const id = await AppReferBilling.appUserId(); // "$ARAnon:..." while anonymous const anonymous = await AppReferBilling.isAnonymous(); await AppReferBilling.setEmail('a@example.com'); // also setDisplayName, setPhoneNumber, setAttributes ``` The install's device id is sent with purchases automatically, so purchases are matched to the ad click without `setUserId`. It is left out while collection is off and after `reset()` until `configure` runs again. The anonymous id is kept in AsyncStorage, so a reinstall starts a new one; a restore still finds the purchases. ### Sandbox and App Review - Test with a sandbox Apple ID or a Play license tester. Those purchases are recorded as sandbox, unlock the entitlement and are never forwarded to ad networks or counted as revenue. - App Review's sandbox purchases in your release build work with the live key. - A real (production) purchase sent with a `pk_test_` key is refused. - Transactions from an Xcode StoreKit configuration file are signed with a local certificate AppRefer rejects; the SDK still unlocks them locally, but they never reach AppRefer. ## Support An in-app help chat your team answers from the AppRefer inbox, with screenshots, quick-help topics, reply notifications and a help centre. Built from React Native core components, no extra runtime packages, 35 languages. Server setup and the inbox are covered in [Support](https://apprefer.com/docs/support.md). ### Open the chat Mount the modal host once, next to your navigation: ```tsx import { AppRefer, AppReferSupportHost, AppReferSupportView } from '@apprefer/react-native-sdk'; function Root() { return ( <> ); } AppRefer.support.open(); // help home AppRefer.support.open('cv_...'); // one conversation AppRefer.support.compose('Problem with my subscription'); // home with the message field filled in // Or embed it in your own screen (use either the host or the view, not both) // { kind: 'home' }, { kind: 'article', slug }, { kind: 'conversation', id } ``` ### Identify and customise ```typescript import { supportTopics } from '@apprefer/react-native-sdk'; await AppRefer.support.identify('Alex', 'alex@example.com'); // user id is sent automatically AppRefer.support.accentColor = '#7954ff'; // hex or rgb(); the palette is derived from it AppRefer.support.title = 'Sparkly support'; // default " support", translated AppRefer.support.teamNames = ['Sam']; // first names on the home AppRefer.support.replyTime = 'a few hours'; // completes "We usually reply in …" AppRefer.support.appIcon = require('./assets/icon.png'); AppRefer.support.topics = [ supportTopics.billing, Object.assign(supportTopics.restore, { action: () => AppReferBilling.restorePurchases() }), supportTopics.bug, { title: 'Workouts', subtitle: 'Logging and plans', icon: 'chat', tint: '#FF9500', prompt: 'Which workout?' }, ]; AppRefer.support.language = 'de'; // force a language; undefined follows the app AppRefer.support.strings = { 'support.home.subtitle': 'What can we fix for you?' }; ``` Quick-help tiles default to `billing`, `restore`, `bug` and `idea`; an empty list hides them. A tile with an `action` runs it instead of starting a message. `icon` is a built-in name (`billing`, `restore`, `bug`, `idea`, `chat`, `chats`, `camera`…) or a render function. The chat follows the app's language, then the device, then English. ### Reply notifications AppRefer sends reply notifications itself: through APNs with your key on iOS and FCM with your service account on Android. It emails a reply only if it stays unread for an hour. Your app owns the notification permission and messaging setup; the SDK adds no messaging dependency. Register the token once `status().pushReady` is true: the APNs device token (hex) on iOS, the FCM token on Android. ```typescript if ((await AppRefer.support.status()).pushReady) { await AppRefer.support.registerPushToken(token); } // On a notification tap (not on background receipt) if (AppRefer.support.handleNotification(remoteMessage.data ?? {})) return; // Before unregistering a device token await AppRefer.support.removePushToken(oldToken); const unread = await AppRefer.support.unreadCount(); // for a badge ``` On Android, replies arrive on the `apprefer_support` channel. Call `registerPushToken` again when the token rotates. **Push environment (0.10.1+).** On iOS the token is registered for the environment the build is signed for, read from the embedded 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. Force one with `AppRefer.support.pushEnvironment = 'sandbox'` or `registerPushToken(token, 'sandbox')`. Android always uses production. ### Help centre Published help articles appear in the chat with no code (0.10.0+): a "Help articles" card on the home, a searchable help centre and a reader. You can open them directly or build your own screens. ```typescript import { SupportError } from '@apprefer/react-native-sdk'; AppRefer.support.openHelpCentre(); // with mounted AppRefer.support.openArticle('restore-purchases'); const articles = await AppRefer.support.articles(); // AppReferHelpArticle[] const results = await AppRefer.support.articles('refund'); // server search, up to 20 try { const article = await AppRefer.support.article('refunds'); // + body (markdown) } catch (e) { if (e instanceof SupportError && e.status === 404) { /* removed or unpublished */ } } ``` Articles come in the chat's language and are cached for five minutes; offline, the last copy is shown. Test keys also see drafts. ### Status ```typescript const status = await AppRefer.support.status(); status.pushReady; // this platform's push verified (on iOS, for this build's environment) status.emailReplies; // customers can answer reply emails status.helpCentreArticles; // number of published articles ``` `status()` follows the server's caching (five minutes once every channel is verified, otherwise 30 seconds), so calling it at every launch is cheap. ### Support without attribution Apps that want only the chat can start Support on its own. It doesn't configure attribution, collect device signals or send analytics. ```typescript AppRefer.support.configure('pk_live_...'); // Forget the name, email and user id and start a fresh anonymous session await AppRefer.support.clearIdentity(); ``` ## Requirements | Requirement | Minimum | | --- | --- | | React Native | 0.70 (React 18) | | iOS | 15.0 | | Android | API 23 | | Peer dependencies | @react-native-async-storage/async-storage 1.17+; for Billing, react-native-iap 17+ with react-native-nitro-modules | ## Changelog highlights | Version | What changed | | --- | --- | | 0.13.1 | Narrow-screen Support text wrapping, measured composer spacing and keyboard anchoring. React Native Support-only apps bundle without the optional purchase package. | | 0.13.0 | Photo/video testimonials from Support, the app or an authenticated coach; usage terms accepted on Submit, signed uploads and receipt-based rewards. See the Support reference for integration. | | 0.12.0 | Verified customer context, resolution actions, offering and optional review cards, outcomes and tailored Support setup. | | 0.11.0 | Version parity release. Nothing on this page changes; run `pod install` after upgrading. | | 0.10.1 | iOS push tokens use the build's signing environment (new native module); new `support.pushEnvironment` and the `environment` argument on `registerPushToken`. | | 0.10.0 | Help centre in the chat; `support.articles`, `article`, `status()`, `openHelpCentre()`, `openArticle()`. Chat in 35 languages with `language` and `strings` overrides. | | 0.9.3 | The Billing account gate set by `reset()` survives a relaunch. | | 0.9.2 | Billing sends no install device id while collection is off or after `reset()`; new `support.clearIdentity()`. | | 0.9.1 | Events carry `app_user_id`; `setCollectionEnabled`; after `reset()` call `configure` again. | | 0.9.0 | `reset()`, Support-only configure, durable event queue, `trackEventResult`, `clearPendingEvents`, `configureResult`. | | 0.8.0 | Offerings, placements and experiments; `purchasePackage`. Products mapped to no entitlement unlock nothing offline. | | 0.7.0 | SKAdNetwork and AdAttributionKit conversion values on iOS; `updateConversionValue()`. | | 0.6.0 | Tracking consent split: `trackingConsentDidChange()` (iOS) and `setTrackingConsent` (Android); requests pick their host from consent. | Full history: the `CHANGELOG.md` in [apprefer-react-native-sdk](https://github.com/AppAgentic/apprefer-react-native-sdk). --- Source: https://apprefer.com/docs/sdk/react-native · Last updated: 2026-10-20 > 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). # Attribution AppRefer tells you which ad, campaign and network brought each install, and uses that to report trials and revenue back to the network. Coming from a general mobile measurement partner? See [how AppRefer compares with AppsFlyer](https://apprefer.com/appsflyer-alternative), including what AppRefer doesn't do (deep linking, fraud protection). ## How it works Call `configure()` when the app starts. On the first launch the SDK sends one request with the install's signals; AppRefer matches it to the ad click that drove it and returns the result. Later launches return the cached result without waiting on the network. **Swift** ```swift import AppRefer let attribution = await AppRefer.configure(apiKey: "pk_live_...") print(attribution?.network ?? "unknown") ``` **Kotlin** ```kotlin import com.apprefer.sdk.AppRefer lifecycleScope.launch { val attribution = AppRefer.configure(this@MainActivity, apiKey = "pk_live_...") Log.d("AppRefer", attribution?.network ?: "unknown") } ``` **Dart** ```dart import 'package:apprefer/apprefer.dart'; final attribution = await AppReferSDK.configure( AppReferConfig(apiKey: 'pk_live_...'), ); print(attribution?.network); ``` **TypeScript** ```typescript import { AppRefer } from '@apprefer/react-native-sdk'; const attribution = await AppRefer.configure({ apiKey: 'pk_live_...' }); console.log(attribution?.network); ``` If you need to tell an organic install from a failed request, use `configureResult()` (SDK 0.9.0+). It returns one of **attributed** (with the attribution), **pending** (the install is waiting for the user's tracking answer, see below), **organic** or **failed**. An Apple Ads match counts as attributed even while the tracking answer is pending. ## What you get back | Field | Description | | --- | --- | | `network` | Where the install came from: `meta`, `google`, `tiktok`, `openai`, `apple_ads`, `organic`, or a source from your link or `utm_source` (for example `email`) | | `matchType` | How the match was made (see the table below) | | `campaignId` | Campaign ID from the click or Apple Ads | | `campaignName` | Campaign name | | `adGroupId` | Ad set or ad group ID | | `adId` | Ad ID | | `keyword` | Search keyword (Apple Ads, Google) | | `fbclid`, `gclid`, `ttclid` | The click ID, when the install matched a click from that network | | `queryParams` | Query parameters recorded on the click | | `customData` | Custom data set on the tracking link | | `attributionId` | AppRefer's ID for this install's attribution | | `trackingDecisionPending` | True while the install waits for the user's tracking answer | | `createdAt` | When the install was attributed | ## Matching order AppRefer tries each method in turn and stops at the first match. The deterministic methods come first; probabilistic matching is the last resort before organic. | Order | Method | matchType | Notes | | --- | --- | --- | --- | | 1 | Apple AdServices | `adservices` | Apple Ads on iOS. Apple runs it without tracking permission, so it works for every install. | | 2 | Play Install Referrer click ID | `install_referrer` | Android. The tracking link passes its click ID through Google Play to the install. | | 3 | Play Install Referrer gclid | `install_referrer_gclid` | Android Google App campaigns that send people straight to Google Play, skipping the AppRefer page. The gclid is kept so purchases still reach Google Ads. | | 4 | Play Install Referrer UTM | `install_referrer_utm` | Android, when the referrer has UTM values but no click ID. | | 5 | Exact IP match | `ip_match` | One click from the same IP address in the last 24 hours, and only one. | | 6 | Probabilistic | `probabilistic` | Scores clicks from the last 24 hours on several signals (IP, network prefix, OS, device). Accepted only when the best click scores high enough and clearly beats the next one. | | 7 | Organic | `organic` | No match. | Methods 2 to 6 need tracking permission (see below). Each click can be claimed by only one install. ## Tracking consent AppRefer never shows the App Tracking Transparency prompt. From SDK 0.6.0 the iOS SDK reads the status your app already has and sends it with the install: **granted**, **denied** (also restricted) or **undetermined** (not asked yet). On Android you pass your own consent with `setTrackingConsent()`. - **Granted:** full matching, conversions are sent to ad networks and advanced matching is kept. - **Denied:** first-party only. The install gets Apple AdServices or organic, nothing is sent to ad networks, and no advertising ID or hashed personal data is stored. - **Undetermined:** the decision is held. The install starts first-party and is not counted in reports yet. When the user answers, the SDK reports it: _granted_ runs the full match once, _denied_ keeps the first-party result. Purchases made in the meantime are queued for about 6.5 hours, so a purchase made just before the user taps Allow is still sent. With no answer after 24 hours the install is finalised as first-party. - **Not sent** (older SDKs, or Android apps that never call `setTrackingConsent()`): behaves as before consent existed, unless the app requires consent (see settings below). AppRefer re-reads the stored consent on every forward, including retries and webhook purchases, so a later change in iOS Settings is honoured. The SDK checks again on every `configure()` and each time the app becomes active. Call `trackingConsentDidChange()` right after your own prompt so the answer is sent straight away. **Swift** ```swift import AppTrackingTransparency _ = await ATTrackingManager.requestTrackingAuthorization() await AppRefer.trackingConsentDidChange() ``` **Kotlin** ```kotlin // After your own consent screen. Null restores the default behaviour. AppRefer.setTrackingConsent(granted = userAllowedTracking) ``` **Dart** ```dart // iOS: after your ATT prompt await AppReferSDK.trackingConsentDidChange(); // Android: pass your own consent await AppReferSDK.setTrackingConsent(userAllowedTracking); ``` **TypeScript** ```typescript // iOS: after your ATT prompt await AppRefer.trackingConsentDidChange(); // Android: pass your own consent await AppRefer.setTrackingConsent(userAllowedTracking); ``` ### Two hosts Requests from installs that allowed tracking go to `trk.apprefer.com`. Everything else (installs that denied or haven't answered, and all Support traffic) goes to `apprefer.com`. AppRefer Billing picks its host the same way. The SDK never falls back from one to the other, so in your iOS privacy manifest you declare only `trk.apprefer.com` under `NSPrivacyTrackingDomains`. ## App settings that change matching Two per-app switches, set with `PATCH /api/v1/apps/{appId}` using an organisation API key with `apps:write`: | Setting | Default | Effect | | --- | --- | --- | | `probabilisticMatching` | `true` | Set to false to turn off probabilistic matching. AppRefer then uses only Apple AdServices, Play Install Referrer click IDs and the exact single-candidate IP match. | | `trackingConsentRequired` | `false` | Set to true when every live version of your app sends consent (SDK 0.6.0+). Installs and purchases with no recorded consent are then treated as not granted, instead of the older default. | ```bash curl -X PATCH https://apprefer.com/api/v1/apps/APP_ID \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{"probabilisticMatching": false, "trackingConsentRequired": true}' ``` ## Country Each install gets a country for reporting. AppRefer uses the matched click's country or Apple Ads' country first, then the country the hosting edge reports for the request, and finally the region in the device locale (for example `en_GB` gives GB). The locale is an approximation, used only when nothing better exists. ## Improve ad network match rates After sign-up or login, call `setAdvancedMatching()` with the user's email or phone. Values are SHA-256 hashed on the device and attached to conversions for installs that allowed tracking. The call does nothing for installs that didn't. Code for each SDK is on the [Events](https://apprefer.com/docs/events.md#advanced-matching) page. > **Linking purchases to installs** > > Call `setUserId()` with the same user ID your purchases use (your AppRefer Billing user or RevenueCat app user ID). Store and webhook purchases are then joined to the install's attribution. --- Source: https://apprefer.com/docs/attribution · Last updated: 2026-10-19 > 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). # Tracking links A tracking link is the URL you put in an ad. It records the click, shows a short page and sends the person to the right store page, so the install can be matched back to the ad. Direct Meta website links can include one public landing selector, such as `?for=music`, in their saved HTTPS destination. The selector is a lowercase slug of up to 64 characters. Other destination query parameters and fragments are not accepted. ## What a tracking link does - **Records the click** with its click ID (`fbclid`, `gclid`, `ttclid`, `oppref` or `twclid`), campaign parameters and the signals used for matching. - **Works out the network** from the click ID, then `utm_source`, then the network set on the link. - **Shows a landing page** suited to the network (see below) and opens the App Store or Google Play. - **Carries the click to Android installs** through the Play Install Referrer, so Android installs match deterministically. ## Link types AppRefer picks the URL path from the link's network when you create it. Copy the exact URL from the **Links** page. | Path | Used for | What the visitor sees | | --- | --- | --- | | `/api/c/{linkId}` | Meta, TikTok and every other source (email, QR codes, influencers, bio links) | An app card that opens the store by itself | | `/g/{linkId}` | Google Ads | A crawlable app page with App Store and Google Play buttons | | `/o/{linkId}` | ChatGPT Ads (beta) | The same crawlable app page, recording a click only with a valid oppref | | `/x/{linkId}` | X Ads (website funnels only) | Passes the visitor on to your website with the click IDs | ## The app card page Links on `/api/c/` show a small card with your app's icon, name and subtitle from the store and a real store button, then open the store automatically. The store details are cached on the link and refreshed in the background about once a week, so a click never waits on a store lookup. - **Instagram preloads.** Instagram loads ad destinations in a hidden view (for example under the comments sheet) where a jump to the store is blocked. The card only opens the store once the page is actually visible, after Meta's Parameter Builder has loaded or 1.5 seconds have passed, and only once. - **Back works.** The jump replaces the page in history, so pressing Back doesn't bounce the visitor to the store again. - **Only seen clicks are counted.** The click is stored for matching straight away, but it is counted in the dashboard only when the page reports that it was shown. Hidden preloads and Meta prefetches that are never opened don't inflate your click numbers. - **Meta prefetches are de-duplicated.** A prefetch from the same IP for the same link within 5 minutes reuses the first click, including its Play Install Referrer. Link previews from crawlers (Facebook, X, LinkedIn, Slack, WhatsApp, search engines and similar bots) see the page but are never recorded as clicks. Links set to `landingStyle: "legacy"` keep the older plain "Redirecting…" page. ## Google Ads landing page Google Ads can reject uncertified click trackers in a final URL, so Google links use a real landing page at `/g/{linkId}`. It does not redirect on its own; the visitor taps the App Store or Google Play button that matches their device, and the click is recorded with the platform and store they were sent to. The page fills itself from your public App Store and Google Play listings (name, description, screenshots, rating). Send a `landingPage` object when you create the link only if you want to override that content. Copy must be about the app, written for the person downloading it. **Optional landingPage** ```json { "headline": "Plan better workouts on your phone", "subheadline": "Plan sessions, compare progress and stay consistent.", "features": [ "Build workouts around your goals", "See progress after every session", "Install from the official app stores" ], "imageUrls": [ "https://example.com/screenshot-1.png", "https://example.com/screenshot-2.png" ], "rating": "4.8 stars", "proof": "Loved by 10k+ people", "testimonial": "Makes my routine simple.", "testimonialAuthor": "App Store review", "faqs": [{ "question": "Is it free to try?", "answer": "Yes, with a free trial." }] } ``` Up to 3 screenshots and 2 FAQs are shown. Google Android App campaigns that send people straight to Google Play, without this page, are still attributed through the `gclid` in the Play Install Referrer (see [Attribution](https://apprefer.com/docs/attribution.md#matching-order)). ## ChatGPT Ads landing page ChatGPT Ads links use `/o/{linkId}`, the same crawlable page as Google. OpenAI adds an opaque `oppref` to real ad clicks; a visit without a valid `oppref` (including OpenAI's review and search crawlers) sees the page but is not recorded. Never create or change `oppref` yourself. ChatGPT Ads has no campaign URL macros, so set the campaign, ad group and ad IDs on the link itself (`externalCampaignId`, `externalAdGroupId`, `externalAdId`). ## X Ads website links X Ads links use `/x/{linkId}` and always send the visitor to your website, for purchases you then report as verified website receipts. The destination must be a clean HTTPS URL with no query string or fragment. A click is recorded only when X supplied a valid `twclid` and the browser did not send a Global Privacy Control opt-out (`Sec-GPC: 1`). The website receives `ar_click_id`, `ar_link_id` and `twclid`. Forwarding to X is off by default for each app; see [X Ads](https://apprefer.com/docs/integrations/x.md). ## Store destinations and custom product pages By default a link opens the App Store and Google Play URLs in your app settings. A link can override each platform, for example to send an ad to an App Store custom product page or a Google Play custom store listing, and carry a short `storeVariantKey` for reporting. **Per-link store destinations** ```json { "storeVariantKey": "creator_a", "iosStoreUrl": "https://apps.apple.com/us/app/example/id1234567890?ppid=abc123", "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.example.app&listing=creator_a" } ``` AppRefer uses the link's URL for the visitor's platform, then an older single `storeUrl` on the link if it is a store URL for that platform, then the app's default store URL, then the link's fallback URL. `iosStoreUrl` must be an App Store URL and `androidStoreUrl` a Google Play URL. `storeVariantKey` takes up to 80 letters, numbers, dots, underscores, tildes or hyphens. > **Android keeps both parameters** > > On Google Play, AppRefer adds its `referrer` (for install matching) and keeps your `listing` parameter, so the custom store listing still shows. ## Setting up each ad network Ad platforms take the link and the tracking parameters in separate fields. Copy both from the Links page. **Meta: Website URL** ``` https://apprefer.com/api/c/your_meta_link ``` **Meta: URL parameters** ``` fbclid={{fbclid}}&campaign_id={{campaign.id}}&campaign_name={{campaign.name}}&adset_id={{adset.id}}&adset_name={{adset.name}}&ad_id={{ad.id}}&ad_name={{ad.name}}&utm_id={{campaign.id}}&utm_name={{campaign.name}} ``` Meta also adds the real `fbclid` to each click. If a placeholder such as `{{fbclid}}` arrives unreplaced, AppRefer ignores it rather than sending a bad value to Meta. **Google: Final URL** ``` https://apprefer.com/g/your_google_link ``` **Google: Final URL suffix** ``` campaign_id={campaignid}&campaign_name={campaignname}&adgroup_id={adgroupid}&keyword={keyword}&utm_id={campaignid}&utm_name={campaignname} ``` Keep Google auto-tagging on; it adds the `gclid`. **TikTok: Destination URL** ``` https://apprefer.com/api/c/your_tiktok_link ``` **TikTok: custom parameters** ``` campaign_id=__CAMPAIGN_ID__&campaign_name=__CAMPAIGN_NAME__&adgroup_id=__AID__&adgroup_name=__AID_NAME__&utm_id=__CAMPAIGN_ID__&utm_name=__CAMPAIGN_NAME__&ttclid=__CLICKID__ ``` **ChatGPT Ads: Destination URL** ``` https://apprefer.com/o/your_openai_link ``` > **Macro syntax differs by network** > > Meta uses `{{campaign.id}}`, Google uses `{campaignid}` and TikTok uses `__CAMPAIGN_ID__`. The ad platform replaces them when the ad is served. ## Query parameters | Parameter | Source | Description | | --- | --- | --- | | fbclid | Meta | Meta click ID | | gclid | Google Ads | Google click ID | | ttclid | TikTok | TikTok click ID | | oppref | ChatGPT Ads | Opaque OpenAI click reference; only read on ChatGPT Ads links | | twclid | X Ads | X click ID; only read on `/x/` links | | campaign\_id / campaign\_name | All | Campaign | | adset\_id / adgroup\_id | All | Ad set or ad group ID | | adset\_name / adgroup\_name | Meta, TikTok | Ad set or ad group name | | ad\_id / ad\_name | Meta | Ad | | keyword | Google | Search keyword | | utm\_source | Any | Source name; sets the network when there is no click ID | | utm\_medium / utm\_campaign | Any | Medium and campaign (utm\_campaign is used when campaign\_name is missing) | ## Creating links On the **Links** page, pick a link type (Meta Ads, Google Ads, TikTok Ads, ChatGPT Ads, Email, Instagram bio, QR code, SMS, referral, influencer, podcast or custom), or use **Create default links** to make Meta, Google and TikTok links in one go. From code, use `POST /api/v1/apps/{appId}/links` with an organisation API key that has `links:write`; see the [API reference](https://apprefer.com/docs/api.md). ```bash curl -X POST https://apprefer.com/api/v1/apps/APP_ID/links \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{ "linkId": "meta_spring_ugc", "name": "Meta spring UGC", "network": "meta", "iosStoreUrl": "https://apps.apple.com/us/app/example/id1234567890?ppid=abc123" }' ``` ## Link performance The **Links** page shows clicks, installs, trials, purchases (new payers), revenue and install rate for every link over the date range you pick. It follows the Live/Sandbox and platform switches, and you can sort by any column. Revenue is net of refunds and is credited to the link of the install it belongs to. Verified website purchases are not tied to a link, so web-funnel revenue appears on the **Campaigns** page instead. See the [Dashboard guide](https://apprefer.com/docs/dashboard.md) for how each number is defined. --- Source: https://apprefer.com/docs/tracking-links · Last updated: 2026-10-19 > 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). # Events Track what people do in your app for product analytics, and choose which of those events are sent to ad networks as conversions. ## Where events come from | Source | How | Typical events | | --- | --- | --- | | Your app | `trackEvent()` in the SDK | sign\_up, tutorial\_complete, paywall\_viewed, level\_complete | | Store purchases | AppRefer Billing store notifications, or [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md) webhooks | Trials, first payments, renewals, refunds, cancellations (no app code needed) | | Your website backend | `POST /api/v1/apps/{appId}/web-events` | Verified website purchases and funnel signals (see below) | Purchases are best reported from the store side. If your app also sends revenue events, turn on **RevenueCat Only** in app settings to ignore revenue the app reports itself, so nothing is counted twice. ## Tracking an event **Swift** ```swift await AppRefer.trackEvent("sign_up") await AppRefer.trackEvent( "tutorial_complete", properties: ["step_count": 5] ) ``` **Kotlin** ```kotlin AppRefer.trackEvent("sign_up") AppRefer.trackEvent( "tutorial_complete", properties = mapOf("step_count" to 5), ) ``` **Dart** ```dart await AppReferSDK.trackEvent('sign_up'); await AppReferSDK.trackEvent( 'tutorial_complete', properties: {'step_count': 5}, ); ``` **TypeScript** ```typescript await AppRefer.trackEvent('sign_up'); await AppRefer.trackEvent('tutorial_complete', { properties: { step_count: 5 }, }); ``` Every event can also carry `revenue` and `currency` (see [Revenue on events](#revenue-on-events)). Names starting with `_` are reserved for the SDK. ## Delivery and the event queue From SDK 0.9.0 every event is saved on the device before it is sent, with a stable `eventId`, and delivered at least once: - Network errors, timeouts (408), rate limits (429) and server errors (5xx) are retried with back-off, and again when the app launches or comes to the foreground. - The queue holds up to 1,000 events for up to 7 days. Events the server rejects for another reason are removed. - The server de-duplicates on `eventId`, so a retry never counts twice. `trackEventResult()` takes the same arguments and tells you what happened: **sent**, **queued** (saved, will retry) or **dropped** (the SDK isn't configured, collection is off, the kill switch is on, or the app called `reset()` and hasn't configured for the next user yet). `clearPendingEvents()` empties the queue. ## Linking events to your user Call `setUserId()` after login with your user ID (the same one your purchases use). From SDK 0.9.1 each event records the `app_user_id` that was set when it was tracked and keeps it through retries, restarts and account switches, so an event queued before a sign-out still belongs to the right person. **Swift** ```swift await AppRefer.setUserId("user_123") ``` **Kotlin** ```kotlin AppRefer.setUserId("user_123") ``` **Dart** ```dart await AppReferSDK.setUserId('user_123'); ``` **TypeScript** ```typescript await AppRefer.setUserId('user_123'); ``` ## Revenue on events Pass `revenue` as a number (a numeric string such as `"9.99"` is also accepted by the API) and `currency` as a three-letter ISO code. AppRefer stores revenue in USD: - Amounts in other currencies are converted with a daily exchange rate. The original amount and currency are kept as `original_revenue` and `original_currency`. - Amounts that can't be converted, aren't numbers, are negative or are over $2,000 are **quarantined**: the event is stored with `quarantined_revenue` and `quarantined_reason`, but it isn't counted as revenue and isn't sent to ad networks. This catches mistakes such as a local price sent as USD. - With **RevenueCat Only** on, events with revenue (and `purchase`, `subscribe`, `startTrial`, `intro_purchase`, `promo_purchase`) from the app are ignored. ## Which events reach ad networks Every event is stored for your analytics. Only some are sent to ad networks: - **Meta and TikTok** receive an event only if it has a mapping you set in **Settings → Event mapping**, a default mapping (table below), or is already one of the network's standard event names (for example `CompleteRegistration`). Everything else, such as `app_opened` or `paywall_viewed`, stays in AppRefer. Clearing a mapping stops that event being sent. - **Google Ads** receives an event only when you have mapped it to a conversion action in **Settings → Conversion actions**. There is no fallback action, so an unmapped event never pollutes another action's bidding. - **ChatGPT Ads** receives trial, subscription and order events by default; see [ChatGPT Ads](https://apprefer.com/docs/integrations/openai.md). **X Ads** receives only verified website purchases, and only when turned on for the app. - An event goes to the network that drove the install. Events from organic installs go to every connected network (Google only when there is a `gclid`), so the networks see the full conversion picture. - Nothing is sent for test keys (`pk_test_`), for installs without tracking permission, for quarantined revenue, or for devices AppRefer has no install record for. ### Default mappings Event names are matched exactly, then in lower case. You can override any row per app. | AppRefer event | Meta | TikTok | | --- | --- | --- | | install, qualified\_install | Lead | Lead | | sign\_up | Lead | Lead | | complete\_registration | CompleteRegistration | CompleteRegistration | | startTrial, qualified\_trial | StartTrial | StartTrial | | purchase, intro\_purchase, promo\_purchase (one-time) | Purchase | Purchase | | Subscription first payment or trial conversion | Subscribe | Purchase | | Subscription renewal | Subscribe | Purchase | | subscribe | Subscribe | Subscribe | | add\_to\_cart | AddToCart | AddToCart | | initiate\_checkout | InitiateCheckout | InitiateCheckout | | search | Search | Search | | view\_content | ViewContent | ViewContent | For installs attributed to Meta, store purchases are split by lifecycle: a subscription's first payment, its trial conversion and each renewal are sent as `Subscribe`, and one-time purchases as `Purchase`. Override the two subscription rows if your ad sets optimise for Purchase. Trial starts can be held back first; see [Qualified trials](https://apprefer.com/docs/qualified-trials.md). ## Deduplication Each forwarded event carries a stable event ID per network, so a retry or a purchase reported by both the store and the app is counted once. Nothing to configure. ## Advanced matching After sign-up or login, send the user's email or phone so ad networks can match conversions more often. Values are SHA-256 hashed on the device, and the call does nothing for installs without tracking permission (SDK 0.6.0+). **Swift** ```swift await AppRefer.setAdvancedMatching( email: "user@example.com", phone: "+14155551234" ) ``` **Kotlin** ```kotlin AppRefer.setAdvancedMatching( email = "user@example.com", phone = "+14155551234", ) ``` **Dart** ```dart await AppReferSDK.setAdvancedMatching( email: 'user@example.com', phoneNumber: '+14155551234', ); ``` **TypeScript** ```typescript await AppRefer.setAdvancedMatching({ email: 'user@example.com', phone: '+14155551234', }); ``` You can also pass first name, last name and date of birth. See [Privacy](https://apprefer.com/docs/privacy.md#on-device-hashing) for how each value is normalised before hashing. ## Verified website payments If people pay on your website after tapping an ad, your payment backend reports those payments with `POST /api/v1/apps/{appId}/web-events`, using a private organisation API key with `apps:write`. It is off by default; turn it on with `PATCH /api/v1/apps/{appId}` and `{"webEvents":{"enabled":true}}`. Send `eventId`, `arClickId`, an anonymous `deviceId`, the original `timestampMs`, `eventType` (`purchase`, `renewal` or `refund`), `revenue` in major units, an ISO `currency`, a clean HTTPS `sourceUrl` and optionally `emailSha256` and `fbp`. A renewal names its first purchase in `originalTransactionId`; a refund names the payment it reverses, carries negative revenue, and refunds can never add up to more than that payment. The click (`arClickId`, passed to your site by the tracking link) must be a verified Meta, Google, TikTok, ChatGPT Ads or X Ads click for the same app, from a website-funnel link whose destination matches `sourceUrl`. Only first purchases are sent to ad networks; renewals and refunds update revenue reporting only. > **Real, settled payments only** > > Send only settled live payments, with any consent your site needs. No sandbox, test, zero-value or unpaid receipts. A 202 means AppRefer has stored the receipt, not that a network has credited it. Retry with the identical event ID and payload; a changed payload for the same ID is refused, and a renewal or refund whose original purchase isn't known yet returns 409 `original_transaction_unavailable` and can be retried later. ### Website funnel signals The same endpoint accepts `sign_up` (no money, sent to Meta as CompleteRegistration), `checkout_started` (selected price, Meta InitiateCheckout) and `start_trial` (the plan price as predicted value, Meta StartTrial and ChatGPT Ads trial\_started). Send `start_trial` only once your payment backend confirms a card-required trial; a later paid conversion is a separate `purchase`. These signals need the same eligible click, use stable event IDs and add no revenue. Website-funnel redirects pass on `offer=trial` or `offer=control` when present and always add `ar_click_id` and `ar_link_id`, so your site can use them for experiment assignment. --- Source: https://apprefer.com/docs/events · Last updated: 2026-10-19 > 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). # Qualified trials Send a trial start to ad networks only after the user has kept it for a while, so the networks learn from trials that are likely to pay. ## Why Ad networks optimise toward the events you send them. Many people start a trial and cancel within minutes. If every trial start is sent, the network learns to find more of those people. Holding the event back until the trial has survived a short window gives the network a cleaner signal of purchase intent. ## How it works | Step | What happens | | --- | --- | | 1\. Trial starts | A store notification (AppRefer Billing) or a RevenueCat webhook reports a new trial for an install attributed to an ad network. Instead of sending it, AppRefer holds it. | | 2\. Hold | The trial waits for the delay you set (2 hours by default, 1 to 24 hours). | | 3a. Still active | When the delay ends, AppRefer sends it as `qualified_trial` to the network that drove the install. | | 3b. Cancelled | If the user cancels during the hold, the trial is disqualified and never sent. | | 3c. Paid early | If a renewal or plan change arrives during the hold, the hold ends. The paid event is sent on its own, so no separate trial event is needed. | Trials from organic installs and test-key (sandbox) purchases are never held. The hold applies to installs attributed to Meta, Google, TikTok and ChatGPT Ads. ## How cancellations are matched A cancellation has to find the right held trial even when the user changed identity in between, and even when the store reports events out of order. AppRefer handles both: - **Across user IDs.** Apps often start a trial under an anonymous ID and log the user in before they cancel. A cancellation is matched to held trials by the install's attribution and by every ID the purchase knows about: the current app user ID, the original app user ID and all aliases. - **Cancellation before the trial.** A cancellation can arrive a second before the trial start it belongs to, when nothing is held yet. Every cancellation therefore also stamps the install's attribution with the time it happened (`lastCancellationAt`). When a held trial is due, AppRefer disqualifies it if that install has a cancellation from 10 minutes before the trial start onwards. ## What is sent | Network | Event | | --- | --- | | Meta | StartTrial | | TikTok | StartTrial | | Google Ads | The conversion action you mapped to `qualified_trial` (nothing is sent without one) | | ChatGPT Ads | trial\_started | You can change the Meta and TikTok event in **Settings → Event mapping**. The original trial time is kept, and the qualified trial also appears as a `qualified_trial` event in your analytics. If the install is still waiting for the user's tracking answer, the event waits too (see [Tracking consent](https://apprefer.com/docs/attribution.md#tracking-consent)); if tracking was denied, it is recorded but not sent. ## Setup 1. Open **Settings → App settings** for your app (admin role needed). 2. Turn on **Qualified Trial**. 3. Set the **delay** in hours, from 1 to 24. Most apps use 2 to 4 hours. The same card shows how many trials qualified, were disqualified or are still pending, and the qualification rate. > **Choosing the delay** > > A longer delay filters out more quick cancellations but gives the ad network its signal later. Look at when most cancellations happen in your app and pick a delay just after that. --- Source: https://apprefer.com/docs/qualified-trials · Last updated: 2026-10-19 > 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). # Qualified purchases Choose which paid purchases count as qualified. In Observe mode, AppRefer checks every purchase against your rules without changing the events sent to ad networks, and your revenue does not change. > **Observe only** > > Every real payment is still counted once in revenue and forwarded exactly as before (Purchase or Subscribe, see [Events](https://apprefer.com/docs/events.md)). Qualified purchases are a separate observation stored beside the payment. Sending them to ad networks will be a later, opt-in step. ## Why Some buyers cancel or ask for a refund within minutes. Watching how many purchases survive a short window, per product, tells you whether a qualified signal would be worth optimising ads for, before anything about your campaigns changes. It is not a verdict on revenue: an annual buyer who turns off auto-renew still paid for the year. ## Rules Each app has up to 10 rules. A payment is checked by the first rule (in order) that covers its type and product. | Setting | What it does | | --- | --- | | Applies to | Any of: first payment, trial converts to paid, renewal, one-time purchase. Free trial starts are never purchases (see Qualified trials for those). | | Products | Store product ids. Empty means every product. | | Window | How long after the payment to watch, 1 to 720 hours. | | Minimum amount paid | Optional, with an explicit currency. A USD minimum uses the converted USD price. Any other currency is compared only with payments made in that currency; others show as Unknown. | | Not refunded or revoked | A refund of this payment, or a revocation of the purchase, inside the window means Not qualified. | | Exclude purchases whose renewal is cancelled during the window | Optional. Turning off auto-renew inside the window (even if it is turned back on) means Not qualified. Never assumed: leave it off for annual plans. | | Required activity | Optional: an SDK event name (for example `workout_completed`) and the minimum times it must be received inside the window. | ## Results | Result | Meaning | | --- | --- | | Waiting | Still inside the observation window. | | Qualified | The payment met every part of the rule. | | Not qualified | Refunded, revoked, auto-renew turned off (when the rule asks), below the minimum amount, or not enough app activity. | | Unknown | AppRefer is missing data it needs to decide, and says why: the store sent no price; the payment was in another currency than the minimum; no AppRefer install was linked to the purchase; the app wasn't sending the activity event before the purchase; AppRefer wasn't collecting that event for the whole window (for about 6 minutes after a new event name is saved, or after a dropped name's 31-day grace period); or the subscription's store history was too long to check the whole window. | Missing data is always Unknown, never Not qualified. A refund, revocation or cancellation that happened inside the window but is delivered late by the store changes a result to Not qualified, and the change is recorded. A refund after the window is noted but does not change the result. ## How payments are matched - **Which purchases are checked.** Purchases that reach AppRefer through AppRefer Billing (App Store, Google Play and Stripe, including purchases the SDK reports) or the RevenueCat webhook. Purchases sent only through the legacy Superwall webhook are not checked. - **One result per payment.** A payment is identified by app, environment (live or test), store and store transaction id. When the same purchase arrives from AppRefer Billing and again from RevenueCat, it is checked once. A test purchase never matches a live one, even with the same ids. - **Sign-in doesn't matter.** Refunds and cancellations are matched by the store's subscription, not by user id, so a purchase made anonymously and cancelled after sign-in is still matched. Google Play subscriptions are matched by their order id (the part before `..`), which both sources share; a refund that names its transaction always reaches that payment. - **Out of order.** A cancellation or refund that arrives before the purchase itself is kept and applied when the purchase lands. Cancellations from up to 10 minutes before the payment count. - **Nothing is dropped silently.** A store notice that can't be recorded is retried, then kept and retried daily; the card says how many are outstanding. Very long store histories keep a marker of what was trimmed, and a payment whose window might be affected is Unknown, never Qualified. - **Rule versions.** Every save creates a new numbered version; versions are never edited. A payment keeps the version it was checked under, so changing a rule never rewrites past results. - **Required activity.** Events are counted for the AppRefer install linked to the purchase (the device id AppRefer Billing and RevenueCat pass along), timed when AppRefer receives them and de-duplicated by event id. Events received before the store's purchase notice are counted too, from the payment time. A new event name is collected within about 6 minutes of saving; a name you rename or remove (or turn the feature off) keeps being collected for 31 days, so payments already waiting are still judged on it. ## Set up 1. Open **Settings → App settings** and find **Qualified Purchases**. Everyone in the company can see it; owners and admins can change it. 2. Turn on **Observe qualified purchases** and start from a template: _Weekly plan: kept 2 hours_ (not refunded, renewal not cancelled within 2 hours) or _Annual plan: not refunded in 14 days_. Pick the products each rule covers. 3. Save. Payments from then on are checked; earlier payments are not. The card shows the last 30 days: how many payments are waiting, qualified, not qualified or unknown, the reasons, a row per rule version and the latest payments (product, amount, result). Test purchases are shown separately. No user, device or transaction ids are shown. > **Annual plans** > > Many annual buyers turn off auto-renew soon after buying and keep using what they paid for. Use "not refunded" for annual plans and keep the auto-renew check for short plans such as weekly. ## Privacy and data Results hold the product, amount, store, times and the outcome. Store transaction ids and install ids are stored only as one-way hashes. Results are kept for 400 days. For a counted event name, AppRefer keeps each install's hashed event ids and receive times for up to 45 days. Erasing a user (see [Privacy](https://apprefer.com/docs/privacy.md)) deletes that activity record and unlinks their installs from any results. --- Source: https://apprefer.com/docs/qualified-purchases · Last updated: 2026-10-27 > 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). # Billing AppRefer Billing runs your App Store, Google Play and Stripe subscriptions itself: it verifies every purchase, keeps each customer’s entitlements and sends the conversions to your ad networks, so you don’t need RevenueCat. ## How it works Two kinds of input feed one ledger. The SDK reports each purchase and restore straight after it happens, and the stores send their own server notifications (App Store Server Notifications V2, Google Play real-time developer notifications and Stripe webhooks), which also cover renewals, refunds and expiries while the app is closed. AppRefer verifies every input with the store (Apple’s signature, the Google Play Developer API, Stripe’s webhook signature), so nothing the app sends can grant access on its own. - Each store subscription or one-time purchase is one record, owned by one **app user id** (your user id, or an anonymous `$ARAnon:` the SDK creates). - The SDK tags every purchase with the customer’s **account token** (Apple `appAccountToken`, Google `obfuscatedAccountId`, Stripe metadata), so a renewal or refund that arrives months later still finds its owner. - Entitlements are worked out from the customer’s records: an active subscription or a store grace period gives access; billing retry, pause, expiry, refunds and revocations don’t. Expiry is checked against the clock, so a missed notification never leaves access on. Consumables never unlock anything, and lifetime products only once paid. - The SDK report and the store notification for the same transaction are counted once, whichever arrives first. ## Modes | billing.mode | What it does | | --- | --- | | off (default) | Billing endpoints answer 404. Nothing is recorded. | | shadow | Records every purchase and serves entitlements to the SDK, but sends no conversions to ad networks. RevenueCat (or whatever runs today) keeps forwarding. Use it to compare AppRefer against RevenueCat before switching. | | primary | AppRefer sends the conversions from its own ledger. For each store AppRefer is connected to (apple.bundleId for the App Store, google.packageName for Google Play, a Stripe webhook for Stripe), the RevenueCat webhook stops forwarding that store’s events and only keeps an ownership link. Stores that aren’t connected keep the full RevenueCat path. | ## Set up 1. Turn billing on for the app and connect each store you sell on (below). 2. Map your products to entitlements. 3. Add AppRefer Billing to the app (SDK 0.5.0+; offerings need 0.8.0+; 0.11.0 is current). 4. Buy with a sandbox account and check the readiness endpoint shows each store reaching AppRefer. 5. Moving from RevenueCat? Start in `shadow`, compare, then switch to `primary` (see [Moving from RevenueCat](#moving-from-revenuecat)). Billing settings live on the app at `billing`. Read them with `GET /api/v1/apps/{appId}/billing` (organization key with `integrations:read`) and change them with `PUT` on the same path (`integrations:write`). PUT merges: send only the sections you change, and `null` removes a section. Secrets (`apple.privateKey`, `google.serviceAccountJson`, `google.pushToken`, `stripe.webhookSecret`, `stripe.apiKey`, `webhook.secret`) are stored encrypted and always come back masked; sending the masked value back keeps the stored one. A mode other than `off` needs at least one store. ```http PUT /api/v1/apps/{appId}/billing Authorization: Bearer ak_live_… (organization key with integrations:write) Content-Type: application/json { "mode": "shadow", "apple": { "bundleId": "com.example.app", "appAppleId": 1234567890, "issuerId": "57246542-96fe-1a63-e053-0824d011072a", "keyId": "2X9R4HXF34", "privateKey": "" }, "google": { "packageName": "com.example.app", "serviceAccountJson": "{\"type\":\"service_account\", …}", "pushToken": "a-long-random-string" }, "products": { "pro_monthly": { "entitlements": ["premium"], "type": "subscription", "period": "P1M" }, "pro_yearly": { "entitlements": ["premium"], "type": "subscription", "period": "P1Y" }, "coins_100": { "entitlements": [], "type": "consumable" } }, "defaultEntitlement": "premium" } ``` Store URLs below use `https://apprefer.com`; if you host AppRefer yourself, use your own domain. ## Connect the App Store | Field | What it is | | --- | --- | | apple.bundleId | Required. Notifications and transactions for any other bundle id are rejected. | | apple.appAppleId | Your app’s numeric Apple ID (App Store Connect → App Information). Production notifications must carry it. Sandbox notifications don’t, so it isn’t checked for them. | | apple.issuerId, apple.keyId, apple.privateKey | An App Store Server API key (an In-App Purchase key, .p8). Optional but recommended: AppRefer uses it to read auto-renew and grace-period status when the SDK reports a subscription purchase. | | apple.forwardTo | Up to 3 https URLs that get each verified notification passed on unchanged (see Moving from RevenueCat). | 1. In App Store Connect → your app → App Information → App Store Server Notifications, set both the **Production** and the **Sandbox** URL to `https://apprefer.com/api/billing/apple/{appId}` and choose **Version 2**. 2. Use **Request a test notification**. The readiness endpoint then shows the time it arrived. AppRefer checks every notification’s signature chain against Apple’s root certificate; an invalid one gets 401. Version 1 notifications can’t be verified, so they are acknowledged and ignored: switch the setting to Version 2. ## Connect Google Play | Field | What it is | | --- | --- | | google.packageName | Required. Notifications for another package are acknowledged and ignored. | | google.serviceAccountJson | A Google Cloud service-account key (JSON) with access to the app in Play Console. | | google.pushToken | A shared secret you put in the push URL as ?token=. Use this or authenticated push. | | google.pushAudience, google.pushServiceAccountEmail | For Pub/Sub authenticated push instead of a token: the audience you set on the subscription and the service account it signs as. Both are required. | 1. In the Google Cloud project, enable the Google Play Android Developer API and create a service account with a JSON key. 2. In Play Console → Users and permissions, invite the service account’s email with access to the app and the permissions **View financial data** and **Manage orders and subscriptions**. AppRefer reads purchases with it and acknowledges them. 3. Create a Pub/Sub topic, give `google-play-developer-notifications@system.gserviceaccount.com` the Pub/Sub Publisher role on it, and select it in Play Console → Monetisation setup → Real-time developer notifications. 4. Add a **push** subscription on the topic with the endpoint `https://apprefer.com/api/billing/google/{appId}?token=`, or turn on authentication with your audience and service account and leave out the token. If another service already reads the topic, add a second subscription; each subscription gets every message. 5. Send a test notification from Play Console and check readiness. A notification only says something changed: AppRefer always reads the current state from the Play Developer API. It acknowledges purchases as soon as it records them (Google refunds purchases left unacknowledged for 3 days), and treats voided purchases as refunds. A push with the wrong token or signature gets 401. ## Connect Stripe (web purchases) Sell on the web through Stripe Checkout with Stripe Managed Payments, using one Stripe account per product. Web purchases then share entitlements and conversions with the same customer’s app purchases. | Field | What it is | | --- | --- | | stripe.webhookSecret | Required. The endpoint’s signing secret (whsec\_…). | | stripe.apiKey | A restricted key (rk\_…) with read access to Subscriptions, Invoices, Checkout Sessions, Charges and Invoice payments. Treat it as required: webhook payloads can be stale or out of order, so AppRefer re-reads the objects. Without it only subscription-object events and refunds of purchases AppRefer already holds are recorded. | | stripe.accountId | The product’s acct\_… id. Events for any other (Connect) account are ignored. | 1. In Stripe → Developers → Webhooks, add the endpoint `https://apprefer.com/api/billing/stripe/{appId}` with the events `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`, `customer.subscription.paused`, `charge.refunded` and optionally `refund.created`. 2. Save the signing secret and restricted key in `billing.stripe`. One secret is stored, so connect the live-mode endpoint for production. To try test mode first, use the test endpoint’s secret and a test key, then replace both. Test-mode events are recorded as sandbox. 3. Give every Price a **lookup key**. AppRefer uses it as the product id (falling back to the price id), so `billing.products` keeps working when you replace a price. 4. Create Checkout Sessions as below. Each Checkout Session must name the customer: - `client_reference_id` = the app user id (Stripe only allows letters, digits, `-` and `_` here; for other ids rely on the metadata). - `metadata.app_user_id` and `subscription_data.metadata.app_user_id` = the app user id, so renewals and refunds find the user without the session. - `subscription_data.metadata.apprefer_account_token` = the customer’s account token. - Optional web-funnel metadata, passed on to attribution as for RevenueCat web purchases: `ar_click_id`, `landing_url`, `fbclid`, `gclid`, `ttclid`, `fbp`, `fbc`, `web_funnel_session_id`, `purchase_event_id` and `utm_*`. ```typescript // Your web backend (Node, stripe-node). accountToken comes from // POST /api/billing/v1/customers for the same appUserId (see below). const session = await stripe.checkout.sessions.create({ mode: "subscription", line_items: [{ price: priceIdForLookupKey("pro_yearly"), quantity: 1 }], client_reference_id: appUserId, metadata: { app_user_id: appUserId }, subscription_data: { metadata: { app_user_id: appUserId, apprefer_account_token: accountToken, // Optional web-funnel signals, passed on to attribution: ar_click_id: arClickId, landing_url: landingUrl, }, }, success_url: "https://example.com/welcome", cancel_url: "https://example.com/pricing", // …plus the Managed Payments settings Stripe gives for your account }); ``` Your backend gets the account token by registering the customer with the app’s SDK key. The response is the customer’s entitlements plus `accountToken`: ```http POST /api/billing/v1/customers X-AppRefer-Key: pk_live_… Content-Type: application/json { "appUserId": "user_123" } 200 { "appUserId": "user_123", "entitlements": {}, "activeSubscriptions": [], "requestDateMs": 1792000000000, "accountToken": "8f14e45f-ceea-467a-9575-7f8b2c0d1e6a" } ``` Amounts are recorded before tax (Managed Payments adds tax on top), in the currency’s real units, including zero-decimal currencies such as JPY. ## Products and entitlements `billing.products` maps each product id to the entitlements it unlocks: the App Store product id, the Google Play subscription id or one-time product id, or the Stripe Price lookup key. | Field | Meaning | | --- | --- | | entitlements | Required list of entitlement ids. An empty list unlocks nothing (credits, consumables, add-ons). | | type | subscription, non\_consumable or consumable. | | period | ISO duration such as P1W, P1M, P1Y. Used for MRR. | | displayName, store | Optional labels for the dashboard. | - A product that isn’t listed unlocks `defaultEntitlement` (`premium` when unset). List every product that should unlock something else, or nothing. - Optionally define the entitlement catalogue in `billing.entitlements`; once it has entries, products may only name those ids. - When several purchases unlock the same entitlement, an active one wins, then the latest expiry. - Mirror the mapping in the SDK’s `entitlementsByProduct`; the SDK uses it only when AppRefer can’t be reached. To choose what a paywall sells from the server, see [Offerings, placements and experiments](https://apprefer.com/docs/offerings.md). ## Add Billing to your app Configure attribution first, then Billing with the same SDK key. On Android, Billing is the `apprefer-billing` module (`com.github.AppAgentic.apprefer-android-sdk:apprefer-billing:0.11.0`, minSdk 23). In React Native it is the `@apprefer/react-native-sdk/billing` entry point and needs `react-native-iap`. Flutter uses the official `in_app_purchase` plugins. iOS needs iOS 15+ (StoreKit 2). **Swift** ```swift import AppRefer Task { await AppRefer.configure(apiKey: "pk_live_...") } AppReferBilling.configure( appUserId: nil, // nil = anonymous "$ARAnon:", kept in the Keychain entitlementsByProduct: ["pro_monthly": ["premium"], "pro_yearly": ["premium"]] ) ``` **Kotlin** ```kotlin import com.apprefer.sdk.AppRefer import com.apprefer.sdk.billing.AppReferBilling CoroutineScope(Dispatchers.IO).launch { AppRefer.configure(this@App, apiKey = "pk_live_...") } AppReferBilling.configure( context = this, appUserId = null, // null = anonymous "$ARAnon:" entitlementsByProduct = mapOf("pro_monthly" to listOf("premium"), "pro_yearly" to listOf("premium")), consumableProductIds = setOf("coins_100"), // one-time products that can be bought again ) ``` **Dart** ```dart import 'package:apprefer/apprefer.dart'; AppReferSDK.configure(AppReferConfig(apiKey: 'pk_live_...')); await AppReferBilling.configure( appUserId: null, // null = anonymous "$ARAnon:" entitlementsByProduct: {'pro_monthly': ['premium'], 'pro_yearly': ['premium']}, consumableProductIds: {'coins_100'}, // Google Play consumables ); ``` **TypeScript** ```typescript import { AppRefer } from '@apprefer/react-native-sdk'; import { AppReferBilling } from '@apprefer/react-native-sdk/billing'; AppRefer.configure({ apiKey: 'pk_live_...' }); await AppReferBilling.configure({ appUserId: null, // null = anonymous "$ARAnon:" entitlementsByProduct: { pro_monthly: ['premium'], pro_yearly: ['premium'] }, consumableProductIds: ['coins_100'], // Google Play consumables }); ``` Let AppRefer Billing finish, acknowledge and consume purchases: don’t call `finishTransaction`, `completePurchase` or the store’s own acknowledge/consume yourself. The attribution device id is sent with purchases automatically, so they are matched to the ad click without extra code. ### Sign in and out `logIn` moves purchases made while anonymous to the signed-in user. Only SDK-made anonymous ids can be merged this way, so a public SDK key can’t pull another real user’s purchases onto a new account. `logOut` starts a fresh anonymous id. **Swift** ```swift let info = try await AppReferBilling.logIn("user_123") await AppReferBilling.setEmail("a@example.com") _ = try await AppReferBilling.logOut() ``` **Kotlin** ```kotlin val info = AppReferBilling.logIn("user_123") AppReferBilling.setEmail("a@example.com") AppReferBilling.logOut() ``` **Dart** ```dart final info = await AppReferBilling.logIn('user_123'); await AppReferBilling.setEmail('a@example.com'); await AppReferBilling.logOut(); ``` **TypeScript** ```typescript const info = await AppReferBilling.logIn('user_123'); await AppReferBilling.setEmail('a@example.com'); await AppReferBilling.logOut(); ``` ### Check an entitlement `customerInfo()` never throws: it uses a cached answer under 5 minutes old, otherwise asks AppRefer, and if AppRefer can’t be reached it unlocks from the store’s own verified purchases, so a paying user is never locked out by an outage. **Swift** ```swift let info = await AppReferBilling.customerInfo() // or customerInfo(fetchPolicy: .fetchCurrent) if info.entitlements["premium"]?.isActive == true { unlock() } for await info in AppReferBilling.customerInfoStream { update(info) } ``` **Kotlin** ```kotlin val info = AppReferBilling.customerInfo() // or customerInfo(AppReferBilling.FetchPolicy.FETCH_CURRENT) if (info.entitlements["premium"]?.isActive == true) unlock() AppReferBilling.customerInfoStream.collect { update(it) } ``` **Dart** ```dart final info = await AppReferBilling.customerInfo(); // or customerInfo(fetchPolicy: FetchPolicy.fetchCurrent) if (info.entitlements['premium']?.isActive == true) unlock(); AppReferBilling.customerInfoStream.listen(update); ``` **TypeScript** ```typescript const info = await AppReferBilling.customerInfo(); // or customerInfo({ fetchPolicy: 'fetchCurrent' }) if (info.entitlements.active.premium) unlock(); const remove = AppReferBilling.addCustomerInfoUpdateListener(update); ``` ### Purchase `purchase` tags the purchase with the customer’s account token, sends it to AppRefer for verification and only then completes it with the store. Who is buying is saved on the device before the sheet opens, so a purchase that completes later (Ask to Buy, a slow payment, after a restart or user switch) still goes to them. To sell from offerings instead of fixed product ids, use `purchase(package:)` (see [Offerings](https://apprefer.com/docs/offerings.md)). **Swift** ```swift let products = try await AppReferBilling.products(["pro_monthly", "pro_yearly"]) let result = try await AppReferBilling.purchase(products[1]) switch result.outcome { case .purchased: unlock(result.customerInfo) case .userCancelled: break case .pending: showPendingNote() // arrives later via customerInfoStream } ``` **Kotlin** ```kotlin val products = AppReferBilling.products(listOf("pro_monthly", "pro_yearly")) val result = AppReferBilling.purchase(activity, products[1]) when (result.outcome) { AppReferBilling.PurchaseResult.Outcome.PURCHASED -> unlock(result.customerInfo) AppReferBilling.PurchaseResult.Outcome.USER_CANCELLED -> Unit AppReferBilling.PurchaseResult.Outcome.PENDING -> showPendingNote() } ``` **Dart** ```dart final products = await AppReferBilling.products(['pro_monthly', 'pro_yearly']); final result = await AppReferBilling.purchase(products[1]); switch (result.outcome) { case PurchaseOutcome.purchased: unlock(result.customerInfo); case PurchaseOutcome.userCancelled: break; case PurchaseOutcome.pending: showPendingNote(); } ``` **TypeScript** ```typescript const products = await AppReferBilling.products(['pro_monthly', 'pro_yearly']); const result = await AppReferBilling.purchase(products[1]); if (result.userCancelled) return; if (result.outcome === 'pending') showPendingNote(); if (result.customerInfo.entitlements.active.premium) unlock(); ``` ### Restore `restorePurchases` syncs with the store and sends every owned purchase to AppRefer as a restore. Only a restore may move a purchase that belongs to another app user to the one signed in; ordinary purchases and renewals never change ownership. **Swift** ```swift let info = try await AppReferBilling.restorePurchases() ``` **Kotlin** ```kotlin val info = AppReferBilling.restorePurchases() ``` **Dart** ```dart final info = await AppReferBilling.restorePurchases(); ``` **TypeScript** ```typescript const info = await AppReferBilling.restorePurchases(); ``` Any purchase whose purchase date is more than 24 hours old when the SDK reports it is treated as a restore or refresh: it updates the customer’s state but is never sent to ad networks as a new conversion. ## Sandbox, TestFlight and App Review - App Review, TestFlight and sandbox Apple IDs buy in the sandbox even in your release build. AppRefer accepts those purchases with your live `pk_live_` key and grants the entitlement, so reviewers can unlock the app. - Sandbox purchases (Apple sandbox, Google Play license testers, Stripe test mode) are recorded as sandbox: kept out of revenue metrics (the dashboard’s sandbox toggle shows them) and never sent to ad networks. - A real production purchase sent with a `pk_test_` key is refused (400). - Transactions from an Xcode StoreKit configuration file are signed with a local certificate, which AppRefer rejects. Test with a sandbox Apple ID; the SDK still unlocks Xcode purchases on the device through its local fallback. ## Conversions sent to ad networks In `primary` mode, each store event runs through the same attribution and forwarding as a RevenueCat webhook, using RevenueCat’s recommended Meta mapping. Prices are converted to USD with daily exchange rates. | Store event | Meta | Other networks | | --- | --- | --- | | Free trial starts | StartTrial, only when qualified trials are on (held, and dropped if cancelled) | StartTrial, same rule | | First paid subscription payment | Subscribe | Purchase (TikTok); your mapped Google Ads action | | Trial converts to paid | Subscribe | Purchase | | Renewal | Subscribe (unless renewal forwarding is off for the app) | Purchase | | One-time purchase | Purchase | Purchase | | Plan change, cancellation, billing issue, expiry, refund | Not sent (recorded only) | Not sent | - Change the Meta event names in the app’s Meta integration settings. See [Qualified trials](https://apprefer.com/docs/qualified-trials.md) for the trial hold. - Family Sharing access and sandbox purchases are never sent. - Forwarding follows the install’s tracking consent (see [Privacy](https://apprefer.com/docs/privacy.md)). Purchases with no ad attribution are still sent to your connected ad networks (Meta, TikTok and ChatGPT Ads, and Google when a gclid is known), which credit only what they can match, as RevenueCat does. - In `shadow` mode nothing is sent. ## Moving from RevenueCat You don’t need a RevenueCat data export. Old app versions can keep buying through RevenueCat while new versions use AppRefer Billing. Still deciding? Our [RevenueCat alternative comparison](https://apprefer.com/revenuecat-alternative) covers prices and when RevenueCat is the better pick. 1. **Shadow first.** Set `mode: "shadow"`, map your products and connect the stores without moving anything off RevenueCat: - App Store: either set RevenueCat’s Apple server notification forwarding URL to the AppRefer URL, or point App Store Connect at AppRefer and put RevenueCat’s notification URL in `apple.forwardTo`. AppRefer passes each verified notification on unchanged, and if a forward fails it answers 500 so Apple retries (recording is idempotent). - Google Play: add a second push subscription on your existing notifications topic. - Stripe: add the AppRefer endpoint next to the existing one. 2. **Ship the SDK.** On first Billing configure for each install and user, the SDK imports the store’s active purchases in the background (StoreKit 2 current entitlements, Google Play owned purchases). They fill in state without moving purchases already owned by someone else, and being older than 24 hours they create no ad conversions. Keep the same app user ids you used with RevenueCat. 3. **Compare.** The Subscriptions page’s “vs RevenueCat” table compares daily counts and revenue while in shadow mode (see [Subscription analytics](https://apprefer.com/docs/subscriptions.md)). 4. **Switch to primary.** Keep the RevenueCat webhook connected for old app versions. For connected stores it no longer forwards; it saves an ownership link instead (the Apple original transaction id or first Google order id, with the app user, device id and attributes), so later renewals of those purchases find their user. Its money events use the same transaction-based id as AppRefer’s own, so each purchase is forwarded once. If a store notification arrives before RevenueCat’s webhook for a purchase AppRefer can’t yet place, the conversion waits up to an hour for RevenueCat’s copy, then is sent unattributed. Once the Stripe webhook is connected, RevenueCat’s Stripe events are skipped too. 5. When no old versions remain, remove RevenueCat (and the `forwardTo` URL). Each SDK’s README has a RevenueCat-to-AppRefer method table; most names are the same (`customerInfo`, `entitlements["premium"]?.isActive`, `logIn`, `restorePurchases`). ## Outbound webhooks Send every billing event to your own backend, in RevenueCat’s webhook shape so an existing RevenueCat handler can be reused. It works in shadow mode too, so you can wire it up before switching. ```http PUT /api/v1/apps/{appId}/billing Authorization: Bearer ak_live_… { "webhook": { "url": "https://api.example.com/apprefer/billing", "secret": "at-least-24-random-characters", "enabled": true } } ``` - Event types: `INITIAL_PURCHASE`, `RENEWAL` (also trial conversions, with `is_trial_conversion: true`), `PRODUCT_CHANGE`, `NON_RENEWING_PURCHASE`, `CANCELLATION` (auto-renew off; also refunds, with a negative price and `cancel_reason: "CUSTOMER_SUPPORT"`), `UNCANCELLATION`, `BILLING_ISSUE`, `SUBSCRIPTION_PAUSED` and `EXPIRATION`. Family-shared access is not sent. - `event.id` is stable per store transaction: make your handler idempotent on it. `mode` is `shadow` or `primary`; while RevenueCat still runs the app, don’t grant access from both webhooks. - `entitlement_ids` lists what the product unlocks (always empty for consumables). If the owner wasn’t known yet, `app_user_id` is an `$ARBillingAnon:` id; the event is not sent again later. - `price` is USD (0 for a trial start); `price_in_purchased_currency` is in `currency`, the purchase currency. Sandbox events have `environment: "SANDBOX"`. ```http POST https://api.example.com/apprefer/billing Content-Type: application/json AppRefer-Signature: t=1792000000,v1=5f2b…c9 { "api_version": "1.0", "app_id": "app_example", "mode": "primary", "event": { "id": "arb_3c9e…", "type": "INITIAL_PURCHASE", "app_user_id": "user_123", "original_app_user_id": "user_123", "aliases": [], "product_id": "pro_yearly", "period_type": "NORMAL", "purchased_at_ms": 1792000000000, "expiration_at_ms": 1823536000000, "event_timestamp_ms": 1792000001000, "environment": "PRODUCTION", "store": "APP_STORE", "price": 39.99, "currency": "USD", "price_in_purchased_currency": 39.99, "is_trial_conversion": false, "transaction_id": "2000000123456789", "original_transaction_id": "2000000123456789", "country_code": "US", "is_family_share": false, "offer_code": null, "subscriber_attributes": { "$email": { "value": "a@example.com" } }, "metadata": { "source": "apprefer_billing", "lifecycle": "initial_purchase", "storeEventId": "…" }, "entitlement_ids": ["premium"] } } ``` Verify every request. The header is `AppRefer-Signature: t=,v1=`, where `v1` is HMAC-SHA256 of `.` with your secret. Reject requests whose `t` is more than 5 minutes away from now. **Node** ```typescript import { createHmac, timingSafeEqual } from "crypto"; // rawBody: the request body exactly as received (a string), before JSON.parse. export function verifyAppReferSignature(rawBody: string, header: string | null, secret: string): boolean { if (!header) return false; const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("="))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 minutes const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex"); const got = Buffer.from(String(parts.v1 ?? ""), "utf8"); const want = Buffer.from(expected, "utf8"); return got.length === want.length && timingSafeEqual(got, want); } ``` **Python** ```python import hashlib, hmac, time def verify_apprefer_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.strip().split("=", 1) for p in header.split(",")) t = parts.get("t", "") if not t.isdigit() or abs(time.time() - int(t)) > 300: return False signed = t.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` | Delivery rule | Value | | --- | --- | | Success | Any 2xx within 5 seconds. Redirects are not followed. | | Retries | First attempt straight away, then after 5 minutes, 30 minutes and every 2 hours, up to 40 attempts (about 3 days). | | Turned off | Pending deliveries pause and resume when you turn the webhook on again (records are kept 30 days). | | URL | https on the default port, a public DNS name (no IP address, localhost, .local, .internal or .run.app hosts), no credentials in the URL. The resolved address is checked again on every send. | | Secret | At least 24 characters. It is never shown again after you save it. | ## Check a customer from your backend For servers that need to know whether a user is premium (a cloud function, a plugin backend), read the same customer info the SDK sees with a private organization key that has `billing:read`. The account token is never returned here. ```http GET /api/v1/apps/{appId}/billing/customers/{appUserId} Authorization: Bearer ak_live_… { "data": { "appUserId": "user_123", "entitlements": { "premium": { "id": "premium", "isActive": true, "productId": "pro_yearly", "store": "app_store", "expiresAtMs": 1823000000000, "willRenew": true, "periodType": "normal", "state": "active", "subscriptionKey": "2000000123456789" } }, "activeSubscriptions": ["pro_yearly"], "requestDateMs": 1792000000000 } } ``` `appUserId` is URL-encoded and up to 256 characters. Billing must be on (404 otherwise). Limited to 600 requests a minute per organization. ## Promotional grants Give one customer an entitlement without a purchase: an App Review account, a customer moved over from another billing system, or goodwill from support. Use a private organization key with `billing:write` (a key with `*` can't; choose the scope on purpose). There is no SDK call for it. ```http PUT /api/v1/apps/{appId}/billing/customers/{appUserId}/entitlements/premium Authorization: Bearer ak_live_… Content-Type: application/json { "expiresAt": "2100-12-31T23:59:59Z", "reason": "App Review account" } { "data": { "grant": { "entitlementId": "premium", "status": "active", "expiresAt": "2100-12-31T23:59:59.000Z", "reason": "App Review account", "grantedAt": "…", "grantedBy": "api:…", "revokedAt": null, … }, "changed": true, "customerInfo": { "entitlements": { "premium": { "isActive": true, "store": "promotional", "productId": "ar_promo_premium", "periodType": "promotional", "willRenew": false, … } }, "activeSubscriptions": [], … } } } ``` - The entitlement must be defined for the app (in the catalogue, or unlocked by a product). `expiresAt` is an ISO 8601 time with a zone, at least a minute ahead and no later than 2100-12-31T23:59:59Z. `reason` is required (up to 500 characters). - One grant per customer and entitlement. Sending the same grant again changes nothing (`changed: false`); a new `expiresAt` replaces the old one. `DELETE` on the same path (optional body `{ "reason" }`) revokes it at once; access then comes from purchases only. - Grants count for the customer and every anonymous id merged into them. When a purchase unlocks the same entitlement, the active one with the later expiry is shown, a lifetime purchase always wins, and an expired or revoked grant never hides a purchase. - A grant shows as `store: "promotional"` and is never a purchase: it isn't in `activeSubscriptions`, revenue, the subscription metrics, outbound webhooks or ad-network conversions. Every change is in the audit log. SDKs read the unfamiliar store as `unknown` and still unlock the entitlement. ## Billing readiness For release checks, `GET /api/v1/apps/{appId}/billing/readiness` with an organization key that has `analytics:read`, `apps:read`, `billing:read` or `integrations:read` shows the mode, which stores are connected, their public ids, whether each secret is set, when each store last reached AppRefer and from which environments, catalogue counts and the outbound webhook state. It returns booleans, ids, counts and times only, never a secret. Notification times are accurate to about 5 minutes and include test notifications, so a test notification proves the URL. Times are recorded from October 2026 onwards. The admin MCP tool `get_app_readiness` returns the same, with Support readiness. ```http GET /api/v1/apps/{appId}/billing/readiness Authorization: Bearer ak_live_… { "data": { "mode": "primary", "apple": { "configured": true, "bundleId": "com.example.app", "appAppleId": 1234567890, "hasServerApiKey": true, "forwardTo": 0, "lastNotificationAt": "2026-10-16T09:00:00.000Z", "environmentsSeen": ["sandbox"], "lastNotificationAtByEnvironment": { "production": null, "sandbox": "2026-10-16T09:00:00.000Z" } }, "google": { "configured": true, "packageName": "com.example.app", "hasServiceAccount": true, "pushAuth": "token", "lastNotificationAt": null }, "stripe": { "configured": false, "accountId": null, "hasApiKey": false, "hasWebhookSecret": false, "lastEventAt": null, "environmentsSeen": [] }, "products": 3, "entitlements": 1, "offerings": 1, "currentOfferingId": "default", "webhook": { "configured": false, "enabled": false } } } ``` ## SDK endpoints The SDKs call these with the public SDK key (`X-AppRefer-Key`). You only need them for a custom client or a web backend. They answer 404 while billing is off, and are limited to 120 requests a minute per IP. | Endpoint | Body / query | Does | | --- | --- | --- | | POST /api/billing/v1/customers | appUserId, appreferId?, attributes? | Registers the customer; returns customer info plus accountToken. | | GET /api/billing/v1/customers | ?appUserId= | Customer info without the account token. | | POST /api/billing/v1/purchases | appUserId, store app\_store + signedTransaction (StoreKit 2 JWS), or store play\_store + purchaseToken, productType, productId (one-time); restore?, presentedOfferingContext?, bucketId? | Verifies, records (and acknowledges on Google) one purchase; returns customer info and purchase. | | POST /api/billing/v1/identify | anonymousId ($ARAnon:), appUserId | logIn: moves the anonymous customer’s purchases to appUserId. | | POST /api/billing/v1/offerings | appUserId, platform, … | See Offerings. | Customer info has this shape (`state` is `active`, `in_grace_period`, `in_billing_retry`, `paused`, `expired` or `revoked`): ```json { "appUserId": "user_123", "entitlements": { "premium": { "id": "premium", "isActive": true, "productId": "pro_yearly", "store": "app_store", "expiresAtMs": 1823000000000, "willRenew": true, "periodType": "normal", "state": "active", "subscriptionKey": "2000000123456789" } }, "activeSubscriptions": ["pro_yearly"], "requestDateMs": 1792000000000 } ``` > **Related** > > [Offerings and experiments](https://apprefer.com/docs/offerings.md) · [Subscription analytics](https://apprefer.com/docs/subscriptions.md) · [RevenueCat integration](https://apprefer.com/docs/integrations/revenuecat.md) --- Source: https://apprefer.com/docs/billing · Last updated: 2026-10-19 > 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). # Offerings, placements and experiments Decide from AppRefer what each paywall sells, for whom, and test it, without hard-coding product ids or shipping an app update. AppRefer Billing uses the same catalogue model as RevenueCat (see [AppRefer vs RevenueCat](https://apprefer.com/revenuecat-alternative)). Edit it in an app’s dashboard under Revenue → Products, Offerings and Experiments, with `PUT /api/v1/apps/{appId}/billing` (organization key with `integrations:write`), or with the admin MCP tools `get_billing_catalog` and `update_billing_catalog`. Each catalogue section (`entitlements`, `offerings`, `currentOfferingId`, `placements`, `targeting`, `experiments`) is replaced whole when you send it. Billing must be set up first: see [Billing](https://apprefer.com/docs/billing.md). ## Concepts | Concept | Meaning | | --- | --- | | Entitlement | Access a customer has, such as premium. Optional catalogue in billing.entitlements; once it has entries, every product and defaultEntitlement may only name listed ids. | | Product | A store product (App Store product id, Google Play subscription or one-time product id, Stripe Price lookup key) with the entitlements it unlocks. An empty list unlocks nothing (credits, add-ons). See Billing → Products and entitlements. | | Offering | A named set of 1 to 12 packages plus optional metadata JSON (up to 8 KB) for paywall copy. Up to 50 offerings. One is the current (default) offering. Archive offerings instead of deleting them; archived ones are never returned. | | Package | One choice on the paywall, with a product per store (app\_store, play\_store, stripe). Ids: $ar\_weekly, $ar\_monthly, $ar\_two\_month, $ar\_three\_month, $ar\_six\_month, $ar\_annual, $ar\_lifetime (RevenueCat $rc\_\* ids are accepted) or a custom id. A Google Play product is subscriptionId:basePlanId, subscriptionId (the SDK picks the base plan matching the package period) or a one-time product id. | | Placement | A paywall location (onboarding, settings…) mapped to an offering, or to null for no paywall. Up to 50. A placement the app asks for that isn’t configured uses the current offering. | | Targeting rule | Up to 20 ordered rules, each with at least one condition: countries (storefront, ISO alpha-2), platforms (ios, android, web) and an app version range (appVersionMin, appVersionMax). The first enabled match sets the current offering. A condition the request can’t answer (no country, unreadable version) doesn’t match. | | Experiment | An A/B test of 2 to 4 offerings (the first is the control) with whole-number weights (default 50), an enrolment share of 1 to 100% and optional audience conditions. Statuses: draft, running, paused, completed. One runs at a time. | ## How the current offering is chosen 1. A running experiment, when the customer matches its audience and falls inside its enrolment share. 2. Otherwise the first enabled targeting rule that matches. 3. Otherwise `currentOfferingId`. Assignment hashes the install’s first anonymous id, so a customer keeps their variant after signing in. While an experiment runs, placements that show the default offering show the customer’s variant instead, so apps that open paywalls through placements still take part. > **Experiments lock once started** > > After an experiment starts, its variants, weights, audience and enrolment share can’t change, and neither can the packages or metadata of the offerings in it, until it is completed. A started experiment can’t be deleted or go back to draft (pause or complete it), and a completed one can’t be restarted: create a new one. Every variant of a running experiment must sell on the same stores, so no platform sees an empty paywall in one arm. ## SDK (0.8.0+) **Swift** ```swift let offerings = try await AppReferBilling.offerings() let offering = offerings.currentOffering(forPlacement: "onboarding") // or offerings.current if let annual = offering?.annual { let result = try await AppReferBilling.purchase(package: annual) } ``` **Kotlin** ```kotlin val offerings = AppReferBilling.offerings() val offering = offerings.currentOffering(forPlacement = "onboarding") // or offerings.current offering?.annual?.let { annual -> val result = AppReferBilling.purchase(activity, annual) } ``` **Dart** ```dart final offerings = await AppReferBilling.offerings(); final offering = offerings.currentOffering('onboarding'); // or offerings.current final annual = offering?.annual; if (annual != null) { final result = await AppReferBilling.purchasePackage(annual); } ``` **TypeScript** ```typescript const offerings = await AppReferBilling.offerings(); const offering = offerings.currentOffering('onboarding'); // or offerings.current const annual = offering?.annual; if (annual) { const result = await AppReferBilling.purchasePackage(annual); } ``` Each offering has `metadata`, `availablePackages`, a package lookup by id and the shortcuts `weekly`, `monthly`, `twoMonth`, `threeMonth`, `sixMonth`, `annual` and `lifetime`. Packages without a product for this platform, or that the store doesn’t return, are left out. The SDK caches offerings in memory and on disk for five minutes, refreshes stale data on foreground and after `logIn`/`logOut`, and falls back to the last cached copy (of any age) when AppRefer can’t be reached. With no cache at all it throws: catch it and fall back to your built-in product ids. Purchasing a package sends its offering context (offering, package, placement, targeting rule, experiment and variant) with the purchase; it is kept on the subscription the first time. ## Endpoint ```http POST /api/billing/v1/offerings X-AppRefer-Key: pk_live_… { "appUserId": "user_1", "bucketId": "$ARAnon:…", "platform": "ios", "country": "GB", "appVersion": "1.5.0", "known": { "appUserId": "user_1", "experimentId": "price_test", "variantId": "b" } } 200 { "currentOfferingId": "default", "offerings": [{ "id": "default", "displayName": "Default", "metadata": {}, "packages": [{ "id": "$ar_annual", "packageType": "annual", "displayName": null, "productId": "com.example.annual" }] }], "placements": { "onboarding": "default", "settings": null }, "context": { "targetingRuleId": null, "experimentId": "price_test", "variantId": "b" }, "requestDateMs": 1792000000000 } ``` `platform` is `ios`, `android` or `web` (Stripe), and only packages with a product for it are returned. `bucketId` is the install-stable id used for experiment assignment (defaults to `appUserId`). `country` is the storefront country. `known` is the context of the SDK’s cached response, so an experiment enrolment is recorded once per app user id. `packageType` is one of `weekly`, `monthly`, `two_month`, `three_month`, `six_month`, `annual`, `lifetime` or `custom`. ## Experiment results Per variant: enrolled customers, trial starts and conversions, paying customers, conversion to paying, gross, refunds and net USD, net revenue per enrolled customer, and the chance each variant beats the control on revenue per customer and on conversion (normal approximation, shown once each arm has 30 customers). Only production purchases made after a customer first saw the experiment count, joined on every app user id seen for the install, with sales deduplicated by store transaction. Revenue is after refunds and before store fees. Wait for about two weeks and a few hundred customers per variant before deciding. Results are cached for five minutes. Read them in the dashboard, with `GET /api/v1/apps/{appId}/billing/experiments/{experimentId}/results` (organization key with `billing:read`), or with the MCP tool `get_experiment_results`. --- Source: https://apprefer.com/docs/offerings · Last updated: 2026-10-19 > 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). # Subscription analytics Revenue, trials, renewals, churn and MRR from AppRefer Billing’s own records of App Store, Google Play and Stripe purchases. Open Revenue in an app’s dashboard. Choose an inclusive UTC date range (up to 731 days), daily, Monday-based weekly or calendar-month buckets, and a store. Production is shown by default; the sandbox toggle shows sandbox purchases only. The numbers come from [AppRefer Billing](https://apprefer.com/docs/billing.md), so the app needs billing in `shadow` or `primary` mode. ## Metric definitions | Metric | Meaning | | --- | --- | | Gross / refunds / net | Gross is paid initial purchases, trial conversions, renewals (including recovered billing), one-time purchases and charged App Store or Google Play upgrades, in USD. Trials and family-shared purchases add no revenue. Refunds are shown as a positive value; net = gross − refunds. Amounts are what the store reports, before store fees; Stripe amounts exclude tax. | | New paid subscriptions | Paid initial purchases of subscription products plus trial conversions. One-time purchases are not included. | | Trials and conversion | Trial starts are initial purchases in a trial period. Conversion is the share of trials started in a bucket that went on to a paid payment by the end of the selected range. Recent cohorts are still maturing: this is observed conversion, not a forecast. | | Renewals | Paid renewals (including recovered billing), excluding family sharing. | | Cancellations | Auto-renew switched off. Access usually continues until expiry. | | Expirations / churn | Expirations count expiry events. Churn is paid subscriptions active at the start of a period and not active at its end, divided by those active at the start. A subscription that lapses and is active again by the end of the period isn’t churned. No subscribers at the start gives 0%. | | Active subscribers / trials | Subscriptions with access at the end of the period, excluding family sharing and one-time products. Grace periods count while they give access. Paid and trial subscriptions are counted separately. Counts are subscriptions, not people: one customer with two store subscriptions counts twice. | | MRR | Monthly recurring revenue in USD from active paid subscriptions, including cancelled ones until they expire, at each subscription’s latest paid price (not the trial price). The period comes from billing.products\[productId\].period (ISO P1W, P1M, P1Y…), then hints in the product id, then the observed purchase-to-expiry length. Yearly ÷ 12, weekly × 52 ÷ 12. Subscriptions with an unknown period or price are left out and counted in quality.unknownMrrPeriods. | | 30-day revenue | Net revenue for the 30 days ending at the selected end date, whatever the start date. | ## Data sources and history Every recorded store event is copied to the BigQuery `billing_ledger` table once it is processed. A failed copy never rejects the store event; a recovery job retries it. Readers remove duplicates by event id and count the SDK report and the store notification for one sale once. Period-end counts are rebuilt from the state saved with each event and the current subscription records. Periods before state was saved with events can’t be fully rebuilt: the response sets `quality.stateIncomplete` and the dashboard flags them. If BigQuery is unavailable, metrics fall back to the operational records (400 days of events, at most 100,000, using current exchange rates), and `source` says `firestore`. Revenue in an unknown currency is reported in `quality.unknownRevenue` rather than treated as USD. Results are cached for five minutes. Self-hosting: create the tables with `web/scripts/setup-bigquery.sh`, then backfill with `npx tsx scripts/backfill-billing-ledger.ts` from `web/` (safe to re-run), and schedule `GET /api/cron/billing-warehouse` every 15 minutes. ## Comparing with RevenueCat (shadow mode) While an app is in `shadow` mode, the “vs RevenueCat” table compares daily counts and USD values per event type between AppRefer’s ledger and what RevenueCat delivered, from a daily reconciliation. It appears for production with all stores selected. RevenueCat events that AppRefer Billing produced itself are excluded. Stripe values show as AppRefer-only unless RevenueCat’s own Stripe integration was also in use. These comparison values follow the reconciliation’s rules, so they are not the net revenue chart. See [Moving from RevenueCat](https://apprefer.com/docs/billing.md#moving-from-revenuecat). ## API and MCP `GET /api/v1/apps/{appId}/subscriptions/metrics` with an organization key that has `apps:read`. Parameters: `start` and `end` (YYYY-MM-DD, inclusive; default the last 30 days; end is capped at today), `bucket` (`day`, `week`, `month`), `store` (`all`, `app_store`, `play_store`, `stripe`) and `sandbox` (`true`/`false`). It returns `options`, `series`, `revenueByStore`, `revenueByProduct`, `kpis`, `quality`, `source` and the shadow `parity` rows. The admin MCP tool `get_subscription_metrics` takes the same fields plus `appId`. ```http GET /api/v1/apps/app_example/subscriptions/metrics?start=2026-09-01&end=2026-09-30&bucket=week&store=all Authorization: Bearer ak_live_… ``` To check that billing is set up before a release, use the [billing readiness endpoint](https://apprefer.com/docs/billing.md#billing-readiness). --- Source: https://apprefer.com/docs/subscriptions · Last updated: 2026-10-19 > 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, 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 <> 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. ## 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 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?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 `) | 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+@` 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-20 > 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). # Integrations AppRefer attributes each install to the ad that brought it, sends your conversions back to that network from the server, and imports ad spend so you can see cost and ROAS next to revenue. Choosing between AppRefer and a separate attribution tool? Read our [AppsFlyer alternative comparison](https://apprefer.com/appsflyer-alternative). ## Ad networks Each network is set up per app, in the dashboard under **Settings → Integrations**, with the REST API (`PATCH /api/v1/apps/{appId}/integrations/{network}`, scope `integrations:write`) or with the MCP tool `configure_integration`. Credentials are encrypted at rest and masked on every read. | Network | Attribution signal | Conversions sent | Spend sync | Guide | | --- | --- | --- | --- | --- | | Meta | `fbclid` on `/api/c/` links, plus the browser `fbc`/`fbp` | Conversions API (dataset), for app and website events | Yes, per ad account, in the account's currency | [Meta](https://apprefer.com/docs/integrations/meta.md) | | Google Ads | `gclid` on the `/g/` landing page or Google Play Install Referrer | Data Manager API, one conversion action per event | Yes, per Customer ID | [Google Ads](https://apprefer.com/docs/integrations/google.md) | | TikTok | `ttclid` on `/api/c/` links | Events API (pixel) | Yes, per advertiser, in the advertiser's currency | [TikTok](https://apprefer.com/docs/integrations/tiktok.md) | | ChatGPT Ads (beta) | `oppref` on the `/o/` landing page | OpenAI Conversions API | Yes, USD ad accounts only, for listed campaign IDs | [ChatGPT Ads](https://apprefer.com/docs/integrations/openai.md) | | X Ads | `twclid` on the `/x/` link (website funnels only) | Web Conversion API, verified website receipts only. Off until you enable it | No | [X Ads](https://apprefer.com/docs/integrations/x.md) | | Apple Ads | Apple AdServices token, sent by the iOS SDK on first launch | None (Apple has no conversions API) | Yes, from the Apple Search Ads API | [Apple Ads](https://apprefer.com/docs/integrations/apple-ads.md) | | SKAdNetwork / AdAttributionKit | Anonymous postbacks from Apple | Conversion values, set on the device by the SDK | No | [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md) | ## Purchases and support | Integration | What it does | Guide | | --- | --- | --- | | AppRefer Billing | App Store, Google Play and Stripe subscriptions verified by AppRefer itself. Every purchase, trial and renewal is attributed and forwarded like any other conversion. | [Billing setup](https://apprefer.com/docs/billing.md) | | RevenueCat (legacy) | Purchase webhooks for apps that still use RevenueCat. New apps should use AppRefer Billing. | [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md) | | Superwall (legacy) | Purchase webhooks for apps that still use Superwall (network `superwall`, field `webhookSecret`). | | | Support delivery | Push (APNs, FCM) and email settings for AppRefer Support replies live on the `support` integration, not on an ad network. | [Support](https://apprefer.com/docs/support.md) | ## How forwarding works 1. **The install is attributed** to one network, or to organic. See [Attribution](https://apprefer.com/docs/attribution.md). 2. **A conversion arrives**: an install or an event from the SDK, a purchase or trial from AppRefer Billing (or a legacy RevenueCat or Superwall webhook), or a verified receipt from your payment backend ([website payments](https://apprefer.com/docs/events.md#verified-website-payments)). 3. **Tracking consent is checked** on every send. An install whose user denied tracking is never forwarded. While the user hasn't answered the iOS prompt yet, conversions are held for about 6.5 hours and sent if they allow it. See [Privacy](https://apprefer.com/docs/privacy.md). 4. **The event is routed.** An attributed install only ever reaches its own network. Organic and unattributed conversions go to every connected Meta, TikTok and ChatGPT Ads integration, and to Google only when a `gclid` is known, so each network's optimisation sees the full picture. X and Apple Ads never receive organic events. 5. **The event name is mapped** to the network's own event. Meta and TikTok only receive events with a default or custom mapping, or that already use one of their standard event names; other in-app events (`app_opened`, `paywall_viewed`…) are dropped. Google only receives events mapped to a conversion action, and X only events mapped to an X event ID. 6. **It is sent from the server** with a stable event ID and the original click ID. Failures are retried automatically with the same event ID, so the network can deduplicate, and every attempt is logged. > **Test data is never forwarded** > > Events sent with a `pk_test_` key and App Store or Google Play sandbox purchases are recorded for debugging but never sent to an ad network. ## Test a connection In **Settings → Integrations**, pick Meta, Google Ads, TikTok or ChatGPT Ads and click **Test Connection**. Google and ChatGPT Ads run a validate-only request that records nothing. Meta and TikTok send one test event: set a **Test Event Code** first so it lands in the network's Test Events view, otherwise it counts as a real event. --- Source: https://apprefer.com/docs/integrations · Last updated: 2026-10-19 > 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). # Meta Ads AppRefer sends your installs, trials and purchases to Meta's Conversions API from the server, matched to the ad click, and imports your Meta spend for cost and ROAS. ## What you need - A Meta dataset (pixel) in Events Manager. - An access token that can send events to that dataset: generate one in Events Manager (dataset → Settings → Conversions API), or use a system user token with access to the dataset. - For spend sync: the ad account IDs, and a token with `ads_read` on them. ## Setup 1. In **Settings → Integrations → Meta**, paste the Dataset ID and access token and save. The integration is connected once both are stored. 2. Optionally add a **Test Event Code** from Events Manager and click **Test Connection**. With a code the test event shows in Test Events; without one it is a real `Lead` event. 3. Add your ad account IDs to turn on spend sync (see below). 4. Point your Meta ads at an AppRefer tracking link. See [Tracking links](https://apprefer.com/docs/tracking-links.md) for the URL parameters. ### Configuration fields | Field | Meaning | | --- | --- | | `datasetId` | Dataset (pixel) that receives events. Required. | | `accessToken` | Conversions API token. Required, secret. | | `webFunnelDatasetId` | Optional second dataset for website conversions: verified website receipts and purchases made in a web funnel checkout. Without it they use datasetId, as do all app events. | | `testEventCode` | Used only by Test Connection. | | `adAccountIds` | Ad accounts to import spend from, for example act\_123456789. | | `costAccessToken` | Optional separate token for spend sync. Use it when the Conversions API token has no ads\_read on the ad account (Meta error #200). Secret. | | `adAccountOwnership` | Which campaigns in each ad account belong to this app (see below). | The dashboard covers the common fields. Set `webFunnelDatasetId`, `costAccessToken` and `adAccountOwnership` with `PATCH /api/v1/apps/{appId}/integrations/meta` or the MCP tool `configure_integration`. Send only the fields you change; stored secrets you leave out are kept. ## What AppRefer sends Each event goes to the dataset with `action_source: "website"`, because the ad click happened on a web tracking link. This lets you run web campaigns while AppRefer closes the loop to the in-app conversion. **Meta Conversions API** ``` POST https://graph.facebook.com/v24.0/{datasetId}/events ``` | Field | Source | | --- | --- | | `fbc` | The click's `fbc` from the landing page, or `fb.1.{clickTime}.{fbclid}`. Never sent when the fbclid looks like an unfilled template such as `{{fbclid}}`. | | `fbp` | Browser ID from the landing page, or from your website receipt. | | `client_ip_address, client_user_agent` | The browser at click time when known, otherwise the device. | | `external_id` | Your hashed user ID from a website receipt, then the hashed AppRefer device ID. | | `em, ph, fn, ln, db, ct, st, zp, country` | Advanced Matching, already SHA-256 hashed. | | `madid` | IDFA or GAID, only for installs that allowed tracking. | | `custom_data` | value and currency. Always present on Purchase (value 0 if unknown). | | `event_source_url` | The website page from a receipt, otherwise the tracking link URL. | ## Event mapping These are the defaults. Change any of them in **Settings → Event Mapping**. Events with no mapping that are not one of Meta's standard event names are not sent, so in-app events such as `app_opened` or `paywall_viewed` never reach Meta unless you map them. | AppRefer event | Meta event | | --- | --- | | install, sign\_up | Lead | | complete\_registration (website sign\_up receipts) | CompleteRegistration | | startTrial, qualified\_trial | StartTrial | | Subscription first payment or trial conversion (subscription\_purchase) | Subscribe | | Subscription renewal (subscription\_renewal) | Subscribe | | One-time purchase, SDK purchase event, website purchase receipt | Purchase | | subscribe (plan change) | Subscribe | | add\_to\_cart | AddToCart | | initiate\_checkout (including website checkout\_started) | InitiateCheckout | | search | Search | | view\_content | ViewContent | Subscription payments follow the mapping RevenueCat recommends for Meta: a subscription's first payment (including intro and promotional offers), its trial conversion and every renewal are `Subscribe`; only one-time purchases are `Purchase`. If your live ad sets optimise for Purchase, map `subscription_purchase` (and `subscription_renewal` if you want) to `Purchase` until you rebuild them. > **Organic purchases** > > The subscription split applies to Meta-attributed installs. Organic and unattributed purchases sent to Meta keep the plain mapping, so their payments arrive as `Purchase`. Trial starts can be held back and only sent once a trial looks likely to convert. See [Qualified trials](https://apprefer.com/docs/qualified-trials.md). ## Advanced Matching Give AppRefer the user's email or phone after sign-up or login. The SDK normalises and SHA-256 hashes each value on the device; nothing readable leaves the phone. **Swift** ```swift await AppRefer.setAdvancedMatching(email: "user@example.com", phone: "+447700900123") ``` **Kotlin** ```kotlin AppRefer.setAdvancedMatching(email = "user@example.com", phone = "+447700900123") ``` **Dart** ```dart await AppReferSDK.setAdvancedMatching(email: 'user@example.com', phoneNumber: '+447700900123'); ``` **TypeScript** ```typescript await AppRefer.setAdvancedMatching({ email: 'user@example.com', phone: '+447700900123' }); ``` All four SDKs also accept `firstName`, `lastName` and `dateOfBirth`. ## Website events Verified receipts from your payment backend ([web events](https://apprefer.com/docs/events.md#verified-website-payments)) are sent to Meta for Meta-attributed clicks: `purchase` as Purchase, `sign_up` as CompleteRegistration, `checkout_started` as InitiateCheckout and `start_trial` as StartTrial. To improve matching, a receipt can carry: - `fbp`: the `_fbp` cookie from your site. Used when the click itself has no browser ID. A value not in `_fbp` format is rejected with `400 invalid_fbp`. - `emailSha256` and the other hashed fields (`firstNameSha256`, `lastNameSha256`, `citySha256`, `stateSha256`, `zipSha256`, `countrySha256`): lowercase hex SHA-256, sent as Advanced Matching. - `externalIdSha256`: the same hashed user ID your Meta pixel sends, so browser and server events line up. > **Test Events never carry fbc** > > When a website receipt is routed to Meta Test Events, AppRefer leaves `fbc` out. Test events still feed your dataset's diagnostics, and a made-up click ID would raise Meta's "modified fbclid" warning against your real traffic. Production events are not affected. Never invent an fbclid to test a live setup. ## Spend sync and shared ad accounts Add each Meta ad account to `adAccountIds`. AppRefer imports campaign spend, impressions and clicks on a schedule (each run refreshes today and yesterday), using `costAccessToken` if set and `accessToken` otherwise. It reads each account's currency from Meta and stores spend in that currency. Totals are only added up when the rows share one currency; mixed currencies are flagged instead, and ROAS is hidden when spend and revenue aren't comparable. An ad account is treated as belonging to this app alone unless you say otherwise. If several apps share an account, give each app an `adAccountOwnership` rule with mode `shared` and the exact campaign IDs or campaign-name prefixes it owns: **PATCH /api/v1/apps/{appId}/integrations/meta** ```json { "adAccountIds": ["act_123456789", "act_987654321"], "adAccountOwnership": { "act_123456789": { "mode": "shared", "campaignNamePrefixes": ["Sparkly |"] }, "act_987654321": { "mode": "dedicated" } } } ``` > **Ownership rules fail closed** > > A shared account with no campaign IDs and no name prefixes imports no spend and records a sync error. Once `adAccountOwnership` exists, every account in `adAccountIds` needs a rule (`dedicated` or `shared`); an account without one is skipped with an error. This stops another app's campaigns being counted against this one. ## Deduplication Every event has a stable `event_id`, reused on retries, so Meta deduplicates repeats within its 48-hour window. Each renewal or repeat purchase gets its own ID. --- Source: https://apprefer.com/docs/integrations/meta · Last updated: 2026-10-19 > 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). # Google Ads AppRefer captures the `gclid` from your Google ads, uploads conversions to the conversion actions you choose through Google's Data Manager API, and imports campaign spend. > **One sign-in per Google Ads account** > > The Google sign-in is stored once per AppRefer organisation and Google Ads Customer ID. Other apps in the same organisation that use the same Customer ID can use it without signing in again; each app keeps its own conversion actions. ## 1\. Connect your Google Ads account 1. Copy your **Customer ID**, the 10-digit number at the top right of Google Ads (XXX-XXX-XXXX). 2. In **Settings → Integrations → Google Ads**, enter it and click **Connect with Google**. 3. Sign in with a Google account that has admin access to that Google Ads account and allow access (Data Manager and Google Ads). You return to AppRefer when it's done. Access tokens last an hour; AppRefer refreshes them with the stored refresh token, so there is nothing to renew by hand. ## 2\. Choose a conversion action per event Google counts and bids on each conversion action separately, so AppRefer only sends an event that you have mapped to an action. There is no fallback action: an unmapped event is skipped rather than counted as the wrong kind of conversion. - **Create in Google (recommended).** In **Settings → Google Ads Conversion Actions**, click **Create in Google** next to an event. AppRefer creates a click-import conversion action with a matching category and fills in its ID. Save to keep it. - **Use an existing action.** In Google Ads, create an offline click-import action (Goals → Conversions → New conversion action → Import → Other data sources or CRMs → Track conversions from clicks), then paste its numeric ID next to the event. | Event key | When it happens | | --- | --- | | `install` | First launch of an install attributed to Google. | | `qualified_install` | Also sent for Google installs with a deterministic match (Play Install Referrer, or a match score of 90 or more). Map it through the API if you want a stricter install signal. | | `startTrial` | A free trial starts. | | `qualified_trial` | A held trial qualifies (see Qualified trials). | | `purchase` | A paid purchase, including trial conversions and every renewal. | | `intro_purchase, promo_purchase` | A paid introductory or promotional first period. | | `subscribe` | A subscription plan change. | | `sign_up, add_to_cart, initiate_checkout, search, view_content` | Matching SDK events. | Mappings are stored in `googleConversionActions` on the Google integration, keyed by event name. ## 3\. Point your ads at AppRefer Google can block click trackers it hasn't certified, so Google ads use a separate landing page instead of the redirect link other networks use. Create a tracking link with network `google` and use its `/g/` URL as the ad's Final URL, with auto-tagging on: **Final URL and Final URL suffix** ``` https://apprefer.com/g/{linkId} campaign_id={campaignid}&campaign_name={campaignname}&adgroup_id={adgroupid}&keyword={keyword} ``` The `/g/` page is a real, crawlable app page with App Store and Google Play buttons. It doesn't redirect by itself. It shows your app's name, icon and screenshots from the store listing, or your own headline, screenshots, features and FAQs if you set them on the link. AppRefer saves the `gclid` when the page loads and records the click once the browser reports which store it is heading to. Native Android App campaigns can send people straight to Google Play instead. When Google Play Install Referrer supplies a `gclid`, the install is attributed to Google with that gclid, with no landing page visit needed. A Play referrer with UTM tags but no gclid stays organic. ## What AppRefer sends **Google Data Manager API** ``` POST https://datamanager.googleapis.com/v1/events:ingest ``` Each upload carries the `gclid`, the event time, the conversion action, the value and currency when known, and a stable transaction ID that is reused on retries. Google doesn't use IP address or user agent for this kind of match, so none is sent. > **A gclid is required** > > Google can only credit a conversion with a `gclid`. Installs without one (organic, or other networks) are never uploaded to Google. If conversions are missing, check that the ad uses the `/g/` Final URL with auto-tagging on, or, for Android App campaigns, that Install Referrer returns a gclid. ## Spend sync Once connected, AppRefer imports campaign spend, impressions and clicks for the main Customer ID and any extra IDs in `customerIds`. Spend is stored in each account's own currency, read from Google; an account that returns no valid currency is refused rather than assumed to be USD. If one Google Ads account runs campaigns for several apps, add a `customerOwnership` rule per Customer ID, with the same shape and fail-closed behaviour as Meta's [ad account ownership](https://apprefer.com/docs/integrations/meta.md#spend-sync-and-shared-ad-accounts): **PATCH /api/v1/apps/{appId}/integrations/google** ```json { "customerIds": ["123-456-7890"], "customerOwnership": { "1234567890": { "mode": "shared", "campaignIds": ["20123456789"] } } } ``` Customer IDs match with or without dashes. A shared account with no campaign IDs or name prefixes imports nothing and records an error. ## Test the connection **Test Connection** sends a validate-only upload to one of your mapped conversion actions. Nothing is recorded in Google Ads. Map at least one action first. ## Troubleshooting | Problem | Fix | | --- | --- | | UNAUTHENTICATED | The sign-in expired or was revoked. Click Connect with Google again. | | PERMISSION\_DENIED | The Google account you signed in with needs admin access to that Google Ads account. | | No conversion action to test against | Map or create at least one conversion action, then test again. | | Conversions not showing yet | Google can take up to 24 hours to show imported conversions. | --- Source: https://apprefer.com/docs/integrations/google · Last updated: 2026-10-19 > 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). # TikTok Ads AppRefer sends your installs, trials and purchases to TikTok's Events API from the server, matched with the `ttclid`, and imports your TikTok spend. ## What you need - A TikTok pixel (Events Manager → Web Events) and its Pixel Code. - A long-lived Marketing API access token (TikTok for Business → Marketing API). - For spend sync: your advertiser IDs. ## Setup 1. In **Settings → Integrations → TikTok**, enter the Pixel Code and access token and save. The integration is connected once both are stored. 2. Optionally add a **Test Event Code** and click **Test Connection**. With a code the event shows in TikTok's Test Events; without one it counts as a real event. 3. Add advertiser IDs to turn on spend sync. 4. Use an AppRefer tracking link as the ad URL and add `ttclid=__CLICKID__` so TikTok fills in its click ID. See [Tracking links](https://apprefer.com/docs/tracking-links.md) for the campaign macros. ## What AppRefer sends **TikTok Events API** ``` POST https://business-api.tiktok.com/open_api/v1.3/event/track/ ``` Events are sent with `event_source: "web"` and your Pixel Code. Each one carries, when known: `user.ttclid`, the browser's IP address and user agent from the click, the TikTok `ttp` browser ID from the landing page, a hashed `external_id`, the hashed email and phone from Advanced Matching, value and currency, the product ID as content, and a stable `event_id` reused on retries. ## Event mapping These are the defaults; change them in **Settings → Event Mapping**. Events with no mapping that are not one of TikTok's standard event names are not sent. | AppRefer event | TikTok event | | --- | --- | | install, sign\_up | Lead | | complete\_registration (website sign\_up receipts) | CompleteRegistration | | startTrial, qualified\_trial | StartTrial | | purchase, intro\_purchase, promo\_purchase (including renewals) | Purchase | | subscribe (plan change) | Subscribe | | add\_to\_cart | AddToCart | | initiate\_checkout | InitiateCheckout | | search | Search | | view\_content | ViewContent | > **ttclid required for attribution** > > A conversion is credited to a TikTok ad through the `ttclid` captured on the tracking link. Make sure every TikTok ad URL includes `ttclid=__CLICKID__`. ## Organic events By default TikTok also receives organic and unattributed installs and purchases, like the other connected networks. Set `organicFanout` to `false` to send TikTok only the events from TikTok clicks, so it can't claim conversions it didn't drive. ## Spend sync Add your advertiser IDs to `advertiserIds`. AppRefer imports campaign spend, impressions and clicks, using `costAccessToken` (a Marketing API token that can read reports) if set, else the access token above. It reads each advertiser's currency from TikTok and stores spend in that currency; an advertiser that returns no valid currency is refused rather than assumed to be USD. If one advertiser account runs campaigns for several apps, add an `advertiserOwnership` rule per advertiser ID, with the same shape and fail-closed behaviour as Meta's [ad account ownership](https://apprefer.com/docs/integrations/meta.md#spend-sync-and-shared-ad-accounts): **PATCH /api/v1/apps/{appId}/integrations/tiktok** ```json { "advertiserIds": ["7012345678901234567"], "advertiserOwnership": { "7012345678901234567": { "mode": "shared", "campaignNamePrefixes": ["Sparkly"] } } } ``` --- Source: https://apprefer.com/docs/integrations/tiktok · Last updated: 2026-10-24 > 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). # Apple Ads AppRefer attributes installs from Apple Ads (Apple Search Ads) through Apple's AdServices framework, with no setup in your app, and can import your Apple Ads spend for cost and ROAS. ## Attribution On first launch the iOS SDK (and the Flutter and React Native SDKs on iOS) asks AdServices for an attribution token and sends it with `configure`. AppRefer exchanges it with Apple and, if Apple says the install came from an Apple Ads campaign, attributes it to network `apple_ads` with match type `adservices`. There are no entitlements or prompts to add; it needs iOS 14.3 or later. - Apple's answer is checked first, before any click matching, because it comes from Apple itself. - It works whatever the user answered to the tracking prompt. An install whose user denied tracking can still be attributed to Apple Ads, but to nothing else. - AppRefer stores the campaign, ad group, keyword, ad (creative set), country or region, conversion type and click date that Apple returns, so you can break revenue down by keyword. - If Apple returns no attribution, the install goes through the normal matching (see [Attribution](https://apprefer.com/docs/attribution.md)). > **No conversions are sent to Apple** > > Apple Ads has no conversions API, so AppRefer never forwards events to it, and Apple Ads installs are never part of the organic fan-out to other networks. Apple measures its own installs, plus [SKAdNetwork postbacks](https://apprefer.com/docs/integrations/skan.md). ## Spend sync To see Apple Ads cost next to revenue, connect an Apple Ads API user. In Apple Ads, go to **Account Settings → API**, create an API user, upload your public key and note the **client ID**, **team ID**, **key ID** and your **organisation ID**. Then generate a client secret (a JWT signed with your private key) as Apple's API guide describes. 1. In **Settings → Integrations → Apple Search Ads**, enter the organisation ID, client ID and client secret and save. 2. If your Apple Ads organisation advertises more than one app, set `adamId` (or `adamIds`) through the API so only this app's campaigns are imported. **PATCH /api/v1/apps/{appId}/integrations/apple_ads** ```json { "orgId": "1234567", "clientId": "SEARCHADS.…", "clientSecret": "eyJ…", "adamId": "1234567890" } ``` | Field | Meaning | | --- | --- | | `orgId` | Apple Ads organisation ID. Required. | | `clientId` | API client ID. Required, secret. | | `clientSecret` | Client secret JWT you generated. Secret. | | `adamId, adamIds` | Your App Store app ID(s). Filters the campaign report to these apps. Without it, every campaign in the organisation counts as this app's spend. | | `granularReports` | Optional. Also import ad group, keyword and search-term spend for the busiest campaigns (15 by default, up to 50 with granularReportCampaignLimit). | The integration shows as connected once the organisation ID, client ID and a client secret are stored; there is no connection test. AppRefer imports campaign spend, impressions and taps per day, stored in the currency Apple reports for each campaign. You can also use the MCP tool `configure_integration` with network `apple_ads`. > **Client secrets expire** > > Apple client secrets last at most 180 days. When yours expires, spend sync stops with an authentication error until you generate a new one and save it. ### Self-hosted deployments If you run AppRefer yourself, it can sign a fresh client secret on every sync instead. Store the private key in an environment variable named `APPLE_ADS__PRIVATE_KEY` and set `teamId`, `keyId` and `privateKeyEnv` (the variable's name) on the integration, leaving `clientSecret` empty. On a deployment shared by several organisations, also set `APPLE_ADS_PRIVATE_KEY_ORGS` (`ENV_NAME=orgA|orgB,OTHER_ENV=orgC`) so each key can only be used by the AppRefer organisations it is bound to; any other organisation naming that variable is refused. --- Source: https://apprefer.com/docs/integrations/apple-ads · Last updated: 2026-10-19 > 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). # ChatGPT Ads (beta) AppRefer attributes installs to self-serve ChatGPT Ads through the `oppref` click reference, sends conversions to OpenAI's Conversions API and imports campaign spend for the campaigns you list. > **Uses your own ads account** > > AppRefer uses credentials from your own OpenAI Ads account. OpenAI must have enabled Ads Manager, a pixel and the APIs for that account; no separate partner setup is needed. ## What you need - An OpenAI Ads pixel ID and Conversions API key. - An Ads API key, if you want spend and ROAS. - The OpenAI campaign IDs that belong to this app. - Your App Store and/or Google Play URLs set on the app in AppRefer. ## 1\. Connect conversions 1. Open **Settings → Integrations → ChatGPT Ads**. 2. Enter the pixel ID and Conversions API key and save. 3. Run the conversions **Test Connection**. It sends a validate-only request that records nothing. Conversions only start once that test has passed. If you change the pixel ID or key later, through the dashboard, the API or MCP, the integration goes back to not connected until you test again. **OpenAI Conversions API** ``` POST https://bzr.openai.com/v1/events?pid={pixelId} ``` ## 2\. Create the tracking link Create a tracking link with network `openai` and use its `/o/` URL as the ad destination: **ChatGPT Ads destination** ``` https://apprefer.com/o/{linkId} ``` The `/o/` page is a crawlable app page with store buttons, like the Google landing page. OpenAI adds its opaque `oppref` to the URL; AppRefer records a click only when a valid `oppref` is present, so OpenAI's review and search crawlers can load the page without creating clicks. On Android the page passes AppRefer's click ID through Google Play Install Referrer for an exact match; on iOS the install is matched to the click. > **Set campaign IDs on each link** > > OpenAI has no campaign macros for the destination URL yet, so set `externalCampaignId`, `externalAdGroupId` and `externalAdId` on each tracking link. The `oppref` alone doesn't tell AppRefer which campaign a click came from. ## 3\. Connect spend reporting 1. Enter the Ads API key and save. 2. Run the reporting **Test Connection**. It reads back the ad account ID, currency and time zone and stores them. 3. Add the campaign IDs that belong to this app (`campaignIds`, up to 200). Only these campaigns are imported, so another app's campaigns in the same account are never counted here. AppRefer imports whole days of campaign spend and re-reads today through three days ago on each regular run. A failed or partial response never replaces a day that was already complete. Spend sync currently works for USD ad accounts only; attribution and conversions work in any currency. Country breakdowns use OpenAI's own country segment. ## Event mapping | AppRefer event | OpenAI event | | --- | --- | | install | app\_installed | | sign\_up, signup, complete\_registration | registration\_completed | | Trial start (startTrial, qualified\_trial) | trial\_started | | First paid subscription period, including intro and promotional offers; subscribe | subscription\_created | | One-time purchase, SDK purchase event, website purchase | order\_created | | add\_to\_cart / initiate\_checkout / view\_content / page\_view | items\_added / checkout\_started / contents\_viewed / page\_viewed | | lead / appointment\_scheduled | lead\_created / appointment\_scheduled | | app\_opened (only if your app tracks an event with this name) | app\_opened | | Renewal, plan change, cancellation, expiration, billing issue, pause | Never sent | Override a mapping in **Settings → Event Mapping**: enter another OpenAI event name, `custom:` for a custom event, or leave it empty to stop sending that event. Unknown events are not sent. With [Qualified trials](https://apprefer.com/docs/qualified-trials.md) on, the trial is held and `trial_started` is sent only once it qualifies. ## Website purchases If the ad leads to your website instead of a store, use a `web_funnel` tracking link. The `/o/` page then sends visitors to your site with `ar_click_id`, and your payment backend reports each settled order to AppRefer with a private organisation key (`apps:write`): - `POST /api/v1/apps/{appId}/web-events`, the general endpoint for every network (see [web events](https://apprefer.com/docs/events.md#verified-website-payments)), or - `POST /api/v1/apps/{appId}/web-purchases`, the older ChatGPT-Ads-only endpoint: `eventId`, `arClickId`, anonymous `deviceId`, original `timestampMs`, `revenue` in major units, `currency`, a generic HTTPS `sourceUrl` (`/`, `/checkout` or `/checkout/confirmation`, no query) and optional adult purchaser `emailSha256`. Unknown fields are rejected. Both return 202 once the order is safely queued (not once OpenAI has credited it); identical retries are deduplicated and a changed payload for the same `eventId` returns 409. Only purchases reach ChatGPT Ads from the website, as `order_created` with `action_source: "web"` and the page URL. No app install is created for them. ## Delivery and privacy - App events are sent with `action_source: "mobile_app"`, the original `oppref`, a stable event ID and the event time in milliseconds. - Amounts are sent in the currency's minor units (cents for USD, whole yen for JPY). - Matching fields: hashed email from Advanced Matching, a hashed user or device ID, and IP address and user agent when known. - Retries reuse the same event ID. Events more than seven days old (OpenAI's limit) are dropped. - Keys are encrypted at rest and masked in the dashboard, API and MCP. --- Source: https://apprefer.com/docs/integrations/openai · Last updated: 2026-10-19 > 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). # X Ads Website conversions from your payment backend to the X Ads Web Conversion API, attributed to the X click that brought the buyer. > **Off until you turn it on** > > The X integration is website-only and disabled for every app by default. Saving credentials never enables it and never calls X. Forwarding starts only after you send `enabled: true`. Spend import is not part of this integration. ## How it works 1. Your X ad points to `https://apprefer.com/x/{linkId}`. X appends its click ID `twclid`. 2. AppRefer records the click only when a valid `twclid` is present and the browser did not send a Global Privacy Control opt-out, then sends the visitor to your website with `ar_click_id`and the same `twclid` (for your X pixel). 3. After a settled payment, your backend posts a verified receipt to `/api/v1/apps/{appId}/web-events` (see [web events](https://apprefer.com/docs/events.md#verified-website-payments)) with that `arClickId`. Web events must be turned on for the app first. 4. AppRefer sends one conversion to X with the original `twclid`, your Ads Manager event ID and your own `eventId` as `conversion_id`. ## 1\. Create the tracking link Use network `x_ads` (not `x` or `twitter`, which stay ordinary redirect links for organic posts). X links must be `web_funnel` links whose `storeUrl` is a clean HTTPS page on your website with no query, fragment or credentials. **X Ads destination** ``` https://apprefer.com/x/{linkId} → https://yoursite.com/?twclid=…&utm_source=x_ads&ar_click_id=…&ar_link_id=… ``` ## 2\. Configure the integration X is configured through the REST API or the MCP tool `configure_integration` (network `x_ads`); there is no dashboard form yet. Send any subset; omitted fields keep their stored value. Unknown fields are rejected with 400. The four OAuth 1.0a values come from an X developer app with Ads API access for the account that owns the pixel. They are encrypted at rest and masked on every read. `eventIds` merges per key; send `""` or `null` to unmap one. **PATCH /api/v1/apps/{appId}/integrations/x_ads (integrations:write)** ```bash curl -X PATCH https://apprefer.com/api/v1/apps/$APP_ID/integrations/x_ads \ -H "Authorization: Bearer $AK_LIVE_KEY" -H "Content-Type: application/json" \ -d '{ "pixelId": "o8z6j", "eventIds": { "purchase": "tw-o8z6j-o8z6k" }, "consumerKey": "…", "consumerSecret": "…", "accessToken": "…", "accessTokenSecret": "…", "enabled": true }' ``` | Field | Meaning | | --- | --- | | enabled | Only the boolean true turns forwarding on. Default false. | | pixelId | X pixel ID (letters and digits). | | eventIds | purchase, complete\_registration, initiate\_checkout, start\_trial → the Ads Manager event ID, stored as entered. | | consumerKey, consumerSecret, accessToken, accessTokenSecret | OAuth 1.0a user-context credentials. Secret. | | connected (read only) | Pixel ID, all four credentials and at least one event ID are stored. It is not a check against X. | ## Event mapping | web-events eventType | X event (only if mapped) | Value | | --- | --- | --- | | purchase | eventIds.purchase | revenue + currency, number\_items 1 | | sign\_up | eventIds.complete\_registration | none | | checkout\_started | eventIds.initiate\_checkout | selected price + currency | | start\_trial | eventIds.start\_trial | predicted value + currency | | renewal, refund | Never sent | Warehouse only | Each paid order is its own `purchase` receipt. A one-off purchase and a later upsell (for example a second version) are two receipts with two `eventId`s, and reach X as two Purchase conversions, each with its own value and currency. ## Deduplication with the X pixel AppRefer sends your receipt `eventId` unchanged as `conversion_id`. If your website also fires the X pixel for the same order, give the pixel the same value as its `conversion_id` so X can count the order once. Retries reuse the same `conversion_id`. ## Delivery and privacy - POST https://ads-api.x.com/12/measurement/conversions/{pixelId}, OAuth 1.0a HMAC-SHA1, one conversion per request. - Identifiers: `twclid`, optional `hashed_email` from `emailSha256`, and the click's IP and browser user agent as a pair when known. - 408, 429, 5xx and network errors stay queued and retry with the same IDs until the receipt is seven days old; other 4xx responses and a 2xx that reports the conversion unprocessed are final. - Forwarding logs keep only flags, the event and conversion IDs, value and an X error code: never the click ID, hashes, IP, user agent, credentials or X's response body. - X never receives Meta, TikTok, Google, ChatGPT Ads, organic or app-store events. --- Source: https://apprefer.com/docs/integrations/x · Last updated: 2026-10-19 > 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). # SKAdNetwork and AdAttributionKit Apple's privacy-preserving attribution works for every iOS install, whatever the user answered to the tracking prompt. AppRefer sets the conversion value for you and collects copies of Apple's postbacks. ## How it works When someone installs your app from an ad, Apple later sends the ad network an anonymous postback with the campaign and a conversion value (0 to 63, plus a coarse low, medium or high). Apple sends no postback until the app sets a conversion value, and only the app can set it. Postbacks arrive in up to three windows after install (days 0 to 2, 3 to 7 and 8 to 35). AppRefer supports both SKAdNetwork and its successor AdAttributionKit. The SDK updates both frameworks, because your ad networks may use either. ## Conversion values (SDK 0.7.0+) The iOS, Flutter and React Native SDKs register each install on first launch (value 0, coarse low). After that they ask AppRefer for the install's value on launch, when the app becomes active, after trial and purchase events and after AppRefer Billing purchases, and set it on the device through AdAttributionKit (iOS 17.4+) and SKAdNetwork (iOS 15+). Values only go up, and windows are never locked early. There is nothing to configure. | Fine value | Coarse | Meaning | | --- | --- | --- | | 0 | low | Opened the app | | 10 | medium | Started a free trial | | 20 + US dollars paid, rounded up (max 63) | high | Paid within 35 days of install, for example $4.99 → 25, $9.99 → 30, $39.99 → 60, $43 or more → 63 | Amounts come only from store-verified purchases (AppRefer Billing, or a RevenueCat webhook), converted to USD and each counted once. SDK events can mark that a trial started or that the user paid, but never set an amount. A reinstall starts a new 35-day window. If you take payments some other way, ask for an update straight after the purchase so the value is set sooner: **Swift** ```swift await AppRefer.updateConversionValue() ``` **Dart** ```dart await AppReferSDK.updateConversionValue(); ``` **TypeScript** ```typescript await AppRefer.updateConversionValue(); ``` The SDK reads the value from `POST /api/track/conversion-value` (SDK key, `{device_id, installed_at}`), which returns `{fine, coarse, windowEndsAt}`. You don't need to call it yourself. > **Tell your ad networks the schema** > > A network can only optimise on conversion values if it knows what they mean. Enter the schema above wherever the network lets you map conversion values. Meta documents importing a schema through its own SDK or its partner measurement providers; we haven't confirmed that Meta accepts this schema for apps measured with AppRefer, so check in Events Manager before relying on Meta's SKAdNetwork reporting. ## Receive postback copies Apple can send you a copy of each winning postback. Set AppRefer as the copy endpoint in your app's `Info.plist`: **Info.plist** ```xml NSAdvertisingAttributionReportEndpoint https://apprefer.com AttributionCopyEndpoint https://apprefer.com ``` Apple then posts to `https://apprefer.com/.well-known/skadnetwork/report-attribution/` and `https://apprefer.com/.well-known/appattribution/report-attribution/`. AppRefer checks Apple's signature on every postback (SKAdNetwork 2.1 and later, and AdAttributionKit), stores each one once, and matches it to your app by its App Store ID. Postbacks with a bad signature are refused. If more than one app in AppRefer claims the same App Store ID, the postback is kept but not assigned to an app. Postbacks are anonymous. AppRefer counts them by ad network, campaign (source identifier), window and value, and never links them to an install or a user. ## Read postbacks **GET /api/v1/apps/{appId}/skan/postbacks (scope skan:read)** ```bash curl "https://apprefer.com/api/v1/apps/$APP_ID/skan/postbacks?days=30" \ -H "Authorization: Bearer $AK_LIVE_KEY" ``` `days` is 1 to 400 (default 30). The response has `installs`, `trials` and `paid` (first-window install postbacks whose value says the install started a trial or paid) and `rows`, each with `framework`, `conversionType`, `adNetworkId`, `sourceIdentifier`, `sequenceIndex` (the window), `conversionValue`, `coarseConversionValue` and `postbacks`. Only production postbacks are counted. The MCP tool `get_skan_postbacks` returns the same summary. > **Works alongside click attribution** > > SKAdNetwork gives aggregate, campaign-level results from Apple. It complements AppRefer's click-based attribution, which on iOS only covers installs whose user allowed tracking. The older conversion-value table under `/api/v1/apps/{appId}/skan` is for display only and doesn't change the values the SDK sets. --- Source: https://apprefer.com/docs/integrations/skan · Last updated: 2026-10-20 > 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). # RevenueCat integration If your app sells through RevenueCat, AppRefer reads RevenueCat’s webhooks to tie each purchase to its ad click and send it to your ad networks, with no purchase tracking code in the app. > **New apps: use AppRefer Billing** > > RevenueCat stays supported for apps that use it. AppRefer Billing does the same job itself for the App Store, Google Play and Stripe (purchases, entitlements, offerings, experiments and conversions), with no data export needed to move over, and this webhook keeps working for old app versions during the switch. See [Billing](https://apprefer.com/docs/billing.md) and [Moving from RevenueCat](https://apprefer.com/docs/billing.md#moving-from-revenuecat). For prices and what each does that the other doesn't, see [AppRefer vs RevenueCat](https://apprefer.com/revenuecat-alternative). ## Why webhooks - Renewals, cancellations, billing issues and expiries arrive even when the app is closed. - No purchase events to track in the app. - One source for the whole subscription lifecycle. ## Set up 1. In AppRefer, open the app’s Settings → Integrations → RevenueCat and click **Generate** to create a webhook secret. The app’s webhook URL is shown there too. 2. In RevenueCat → Project settings → Integrations → Webhooks, add a webhook with the URL `https://apprefer.com/api/webhook/revenuecat/{appId}` (your own domain if you host AppRefer yourself). 3. Set the **Authorization header** value to `Bearer `. Any other value gets 401. 4. Send **all event types**. Sandbox events are accepted, recorded as sandbox and never sent to ad networks. 5. Send a test event from RevenueCat and check that it shows up in the app’s Live events page. ## Link the AppRefer device id > **Required for attribution** > > Set the `appreferId` subscriber attribute in RevenueCat to the AppRefer device id. It is the surest way for AppRefer to find the install, and its ad click, behind each RevenueCat event. Call this right after configuring AppRefer, before the user can buy anything: **Swift** ```swift await AppRefer.configure(apiKey: "pk_live_...") if let deviceId = await AppRefer.getDeviceId() { Purchases.shared.attribution.setAttributes(["appreferId": deviceId]) } ``` **Kotlin** ```kotlin AppRefer.configure(context, apiKey = "pk_live_...") AppRefer.getDeviceId()?.let { deviceId -> Purchases.sharedInstance.setAttributes(mapOf("appreferId" to deviceId)) } ``` **Dart** ```dart await AppReferSDK.configure(AppReferConfig(apiKey: 'pk_live_...')); final deviceId = await AppReferSDK.getDeviceId(); if (deviceId != null) { await Purchases.setAttributes({'appreferId': deviceId}); } ``` **TypeScript** ```typescript await AppRefer.configure({ apiKey: 'pk_live_...' }); const deviceId = await AppRefer.getDeviceId(); if (deviceId) { Purchases.setAttributes({ appreferId: deviceId }); } ``` For each event, AppRefer looks for the install’s attribution in this order: 1. The `appreferId` attribute (`$appreferId` is also read). 2. The RevenueCat app user id, original app user id and aliases, matched to the user id you set with AppRefer’s `setUserId`. 3. The `$idfa` or `$gpsAdId` attribute. 4. The hashed `$email` attribute. With no match the event is still recorded (as unattributed), and paid purchases are still sent to your connected ad networks (Meta, TikTok and ChatGPT Ads, and Google when a gclid is known), which credit only what they can match. RevenueCat’s `$ip`, `$email`, `$phoneNumber` and `$displayName` attributes are used for matching quality when present. ## Event mapping | RevenueCat event | AppRefer event | | --- | --- | | INITIAL\_PURCHASE | startTrial (trial period), intro\_purchase (intro offer), promo\_purchase (promotional), otherwise purchase | | RENEWAL | purchase (trial conversions are flagged is\_trial\_conversion) | | PRODUCT\_CHANGE | subscribe | | NON\_RENEWING\_PURCHASE | purchase | | CANCELLATION | cancellation | | UNCANCELLATION | reactivation | | BILLING\_ISSUE | billing\_issue | | SUBSCRIPTION\_PAUSED | pause | | SUBSCRIPTION\_EXTENDED | subscription\_extended | | EXPIRATION | expiration | | TRANSFER | transfer | | Anything else | The RevenueCat type in lower case | ## What is sent to ad networks | Event | Sent? | | --- | --- | | INITIAL\_PURCHASE, paid | Yes | | INITIAL\_PURCHASE, free trial | Only as StartTrial when [qualified trials](https://apprefer.com/docs/qualified-trials.md) are on for an attributed install (held for the delay, dropped if cancelled) | | RENEWAL, paid | Yes. Trial conversions always; ordinary renewals unless renewal forwarding is off for the app | | NON\_RENEWING\_PURCHASE | Yes | | PRODUCT\_CHANGE, CANCELLATION, UNCANCELLATION, BILLING\_ISSUE, SUBSCRIPTION\_PAUSED, EXPIRATION, TRANSFER and others | No (recorded only) | | Any sandbox event | No | Meta follows RevenueCat’s recommended mapping: a subscription’s first payment, its trial conversion and every renewal are Subscribe, one-time purchases are Purchase and trial starts are StartTrial. You can change the names in the app’s Meta integration settings. Forwarding also follows each install’s tracking consent (see [Privacy](https://apprefer.com/docs/privacy.md)). Qualified trials keep the purchase surface and the user’s IP and user agent through the delay and retries, so web-funnel trials still go to the web-funnel Meta dataset. ## Duplicates RevenueCat may deliver an event more than once. AppRefer processes each event id once and remembers processed ids for 30 days. ## Alongside AppRefer Billing When an app runs AppRefer Billing in `primary` mode, keep this webhook connected while old app versions still buy through RevenueCat. For each store AppRefer Billing is connected to, RevenueCat’s events are no longer forwarded from here: AppRefer saves which app user owns the purchase so later store notifications find them, and money events share AppRefer Billing’s transaction-based id, so each purchase is forwarded once. Stores not connected to AppRefer Billing keep the full RevenueCat path. Once AppRefer’s own Stripe webhook is set up, RevenueCat’s Stripe events are skipped so web purchases aren’t counted twice. --- Source: https://apprefer.com/docs/integrations/revenuecat · Last updated: 2026-10-19 > 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). # REST API Manage apps, links and integrations, read analytics and subscriptions, and send server-side events from your own backend. All endpoints live under `https://apprefer.com/api/v1` (on a self-hosted AppRefer, your own domain). The API is for servers: an organisation API key is a secret, so never put one in an app or a web page. Apps talk to AppRefer through the SDKs with the public `pk_` SDK key instead. ## Agent workspace and login endpoints `POST /api/agent/workspaces` starts a test workspace with a scoped seven-day key and human claim link. `GET /api/agent/workspaces/{id}` returns its credential-bound status. Existing access-request endpoints remain supported. For exact payloads, limits, MCP OAuth discovery, PKCE, rotating tokens, approvals and revocation, read [auth.md](https://apprefer.com/auth.md) and [AI agents & MCP](https://apprefer.com/docs/mcp.md). ## Authentication Send an organisation API key as a bearer token on every request: ```http GET /api/v1/apps Authorization: Bearer ak_live_... ``` - Create keys in the dashboard under **API keys** (account menu at the foot of the sidebar). You need the admin role. The full key is shown once; AppRefer stores only its hash. - A key belongs to one company and reaches only that company's apps. Any other app id answers `404`, exactly like an app that doesn't exist. - Revoking a key in the dashboard stops it everywhere within a minute. ## Scopes Each key carries a list of scopes. A request without the scope its endpoint needs gets `403 FORBIDDEN`, and the message names the scope. Give each key only what its job needs, for example `users:erase` alone for an account-deletion worker. The dashboard offers three presets: **Full Access** (`*`), **Read Only** (the five `:read` scopes for analytics, apps, links, integrations and SKAN) and **Analytics Only**. | Scope | Allows | | --- | --- | | `*` | Everything below. | | `analytics:read` | Stats, overview, campaigns, events, live events, attributions and the portfolio. Also reads support and billing readiness. | | `apps:read` | List apps and read an app's settings and SDK keys, subscription metrics and help articles. Also reads support and billing readiness. | | `apps:write` | Create, update and delete apps, rotate the live SDK key, send website events and purchases, and save help articles. | | `links:read` | List tracking links. | | `links:write` | Create and delete tracking links. | | `integrations:read` | Read ad network, RevenueCat, Superwall and Support delivery settings, and the billing configuration (secrets are always masked). Also reads billing readiness. | | `integrations:write` | Change those integrations and the billing configuration and catalogue. | | `skan:read` | Read the SKAN conversion-value table and the postback summary. | | `skan:write` | Save the SKAN conversion-value table. | | `billing:read` | Look up a customer's entitlements, read offering experiment results and billing readiness. | | `billing:write` | Grant and revoke promotional entitlements. The standard `*` scope also includes this permission. | | `users:erase` | Erase an end user (account deletion) and read the status of an erase request. | | `support:agent` | Run an AI support agent: claim the answer queue, post answers, read and write its knowledge and the app's help articles (new articles default to AI-only). Also reads support readiness. | | `support:context` | Send Support the customer context your backend knows (credits, orders, jobs, failures) and issue identity tokens. The standard `*` scope also includes this permission. | | `support:testimonials` | Register photo/video testimonial campaigns, ask a user for one, claim and report rewards, and export the approved library. The standard `*` scope also includes this permission. | | `support:actions` | Register the Support actions your backend can perform, claim runs your team or the AI asked for, and report their results. The standard `*` scope also includes this permission. | ## Responses and errors Most endpoints wrap their result in `data`; lists that page add `meta`. Errors use one shape: ```json { "data": { "appId": "app_3f9c2a1b7d4e", "name": "Sparkly" } } { "error": { "code": "FORBIDDEN", "message": "Missing required scope: links:write" } } ``` | Status | Code | When | | --- | --- | --- | | 400 | `BAD_REQUEST` | A missing or invalid field. The message says which. | | 401 | `UNAUTHORIZED` | No key, an unknown key or a revoked key. | | 403 | `FORBIDDEN` | The key lacks the scope this endpoint needs. | | 404 | `NOT_FOUND` | The app, link or record doesn't exist, or belongs to another company. | | 409 | varies | A conflict: the link id is taken, or an idempotent request was retried with different data. | | 429 | `RATE_LIMITED` | Too many requests; see below. | | 503 | `SERVICE_UNAVAILABLE` | A backing service (for example the analytics warehouse) is unavailable. Retry later. | A few server-to-server endpoints answer without the `data` wrapper, because their callers retry on the status alone: `users/erase` and its status, `web-events` (errors are `{ "error": { "code": "invalid_click_id" } }`), `web-purchases` (errors are `{ "error": "message" }`) and the support agent endpoints. ## Rate limits Requests from all of a company's keys share one one-minute window. A request is refused with `429` once the window already holds more requests than that endpoint allows. The response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds). Back off and retry after the reset. | Endpoints | Per minute | | --- | --- | | Reads, website events, support agent queue and answers | 120 | | Writes (apps, links, integrations, billing configuration, SKAN, help articles, agent knowledge PUT) | 30 | | Billing customer lookup, support context, identity tokens and support actions | 600 | | Experiment results, new erase requests, agent knowledge GET | 60 | ## Pagination `/attributions` and `/events` page with a cursor. Pass `limit` (1 to 500, default 50) and, for the next page, the `cursor` from the previous response. When `meta.hasMore` is `false` there is nothing more. Other lists return everything in one response. ```json { "data": [ ... ], "meta": { "cursor": "eyJpZCI6ImFwcF8zZjljX2Rldl8xMjMifQ==", "hasMore": true } } ``` Analytics endpoints take `days` (1 to 365, default 14) and report live (production) data only. Revenue is net USD: paid revenue minus refunds. ## Endpoints Paths below are relative to `/api/v1`. ### Apps | Endpoint | Scope | What it does | | --- | --- | --- | | `GET /apps` | apps:read | Your company's apps: appId, name, sdkEnabled, storeUrl, createdAt. | | `POST /apps` | apps:write | Create an app from `{ name, iosUrl?, androidUrl? }`. Returns appId and both SDK keys (`sdkKeyLive`, `sdkKeyTest`). | | `GET /apps/{appId}` | apps:read | One app: name, SDK keys, store URLs, the settings below and its integrations with secrets masked. | | `PATCH /apps/{appId}` | apps:write | Change `name`, `storeUrl` (`{ ios, android }`) or any setting below. Other fields are ignored. Returns the fields updated. | | `DELETE /apps/{appId}` | apps:write | Delete the app (its SDK keys stop resolving). | | `POST /apps/{appId}/sdk-key/rotate` | apps:write | Replace the live SDK key (`pk_live_`) and return the new one. The old key stops working at once, so every app build that still uses it loses attribution, events, Billing and Support until users update. Plan a release with the new key before rotating, and only rotate a key that has leaked. The test key is unchanged. | App settings you can change with `PATCH /apps/{appId}`: | Field | Default | Effect | | --- | --- | --- | | `sdkEnabled` | true | Kill switch. false tells every SDK install to stop sending. | | `probabilisticMatching` | true | false turns off multi-signal probabilistic matching. Installs then match only through Apple AdServices, Play Install Referrer click ids and an exact single-candidate IP match. | | `trackingConsentRequired` | false | true treats installs with no tracking consent recorded (SDKs before 0.6.0, purchases not tied to an install) as not granted: no click matching or ad-network forwarding for them. Turn it on once every live app version sends consent. | | `webEvents` | { enabled: false } | Accept verified website receipts on `/web-events`. The object takes only `enabled`. | | `supportAi` | { mode: "off" } | The support AI policy and persona. Replaced whole, so always send `mode`. See [Support: AI answers](https://apprefer.com/docs/support.md#ai-answers). | **Turn off probabilistic matching and require consent** ```bash curl -X PATCH "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e" \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{ "probabilisticMatching": false, "trackingConsentRequired": true }' { "data": { "appId": "app_3f9c2a1b7d4e", "updated": ["trackingConsentRequired", "probabilisticMatching"] } } ``` ### Tracking links | Endpoint | Scope | What it does | | --- | --- | --- | | `GET /apps/{appId}/links` | links:read | Every link, newest first: id, name, network, url, destinationMode, store destinations, external ids, clicks, installs. | | `POST /apps/{appId}/links` | links:write | Create a link. Body: `linkId` (3 to 50 letters, digits and dashes), `name`, `network?` (default `organic`), `storeUrl?`, `iosStoreUrl?`, `androidStoreUrl?`, `storeVariantKey?`, `landingPage?` (Google and ChatGPT Ads links), `externalCampaignId?`, `externalAdGroupId?`, `externalAdId?`. A taken id is `409`. | | `DELETE /apps/{appId}/links/{linkId}` | links:write | Delete a link. | The returned `url` depends on the network: `/api/c/` for most, `/g/` for Google Ads, `/o/` for ChatGPT Ads and `/x/` for X Ads (which also needs a clean HTTPS `storeUrl`). See [Tracking links](https://apprefer.com/docs/tracking-links.md). **Create a Meta link** ```bash curl -X POST "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/links" \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{ "linkId": "spring-sale", "name": "Spring sale", "network": "meta" }' { "data": { "id": "spring-sale", "name": "Spring sale", "network": "meta", "url": "https://apprefer.com/api/c/spring-sale", "destinationMode": "legacy_redirect", "storeUrl": "", "clicks": 0, "installs": 0 } } ``` ### Integrations | Endpoint | Scope | What it does | | --- | --- | --- | | `GET /apps/{appId}/integrations` | integrations:read | Every configured integration with its connection status. Secrets come back masked (starting with •). | | `PATCH /apps/{appId}/integrations/{network}` | integrations:write | Update one integration. `network`: `meta`, `google`, `tiktok`, `openai`, `x_ads`, `apple_ads`, `revenuecat`, `superwall` or `support`. Send only the fields you change; a masked value sent back keeps the stored secret. Returns `connected` (plus `reportingConnected` for ChatGPT Ads and `enabled` for X Ads, which stays off until you set `enabled: true`). | Fields per network are on each integration page: [Meta](https://apprefer.com/docs/integrations/meta.md), [Google](https://apprefer.com/docs/integrations/google.md), [TikTok](https://apprefer.com/docs/integrations/tiktok.md), [ChatGPT Ads](https://apprefer.com/docs/integrations/openai.md), [X Ads](https://apprefer.com/docs/integrations/x.md), [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md). The `support` network holds push and email delivery (see [Support](https://apprefer.com/docs/support.md#notifications-and-email)). ### Analytics | Endpoint | Scope | What it does | | --- | --- | --- | | `GET /apps/{appId}/stats?days=` | analytics:read | KPIs (installs, revenue, grossRevenue, refunds, trials, purchases) and a daily chart. | | `GET /apps/{appId}/overview?days=` | analytics:read | KPIs, chart data, a breakdown by network and the top campaigns. | | `GET /apps/{appId}/campaigns?days=&network=&groupBy=` | analytics:read | Clicks, installs, revenue and synced spend per row, plus totals. `network`: meta, google, tiktok, apple\_ads, openai or x\_ads (default all). `groupBy`: campaign (default), adgroup, ad, keyword, searchterm or country. | | `GET /apps/{appId}/events?limit=&cursor=&deviceId=` | analytics:read | The event log, newest first, paged. deviceId filters to one install. | | `GET /apps/{appId}/events/live?limit=` | analytics:read | The most recent events (limit up to 100), for a live view. | | `GET /apps/{appId}/attributions?limit=&cursor=` | analytics:read | Install attributions, newest first, paged: id, deviceId, network, campaignName, matchType, createdAt. Test-key installs are left out. | | `GET /portfolio?days=` | analytics:read | Totals and a per-app comparison across every app of your company. | Event names and properties are described in [Events](https://apprefer.com/docs/events.md). ### Billing and subscriptions | Endpoint | Scope | What it does | | --- | --- | --- | | `GET /apps/{appId}/billing` | integrations:read | The AppRefer Billing configuration (mode, stores, products, catalogue, outbound webhook) with secrets masked. | | `PUT /apps/{appId}/billing` | integrations:write | Merge an update: `mode` (off, shadow or primary), `apple`, `google`, `stripe`, `webhook`, `products`, `defaultEntitlement`, and the catalogue sections `entitlements`, `offerings`, `currentOfferingId`, `placements`, `targeting`, `experiments` (each catalogue section sent is replaced whole). Validated before saving; returns the new configuration, masked. | | `GET /apps/{appId}/billing/customers/{appUserId}` | billing:read | Server-side entitlement check for your own backend: the same customer info the SDK sees (`entitlements`, `activeSubscriptions`). URL-encode the user id. `404` when Billing is off for the app. | | `PUT /apps/{appId}/billing/customers/{appUserId}/entitlements/{entitlementId}` | billing:write | Grant an entitlement with no purchase (App Review accounts, migrated customers, goodwill). Body `{ expiresAt, reason }`: an ISO 8601 time with a zone, at least a minute ahead and no later than 2100-12-31T23:59:59Z, and why (up to 500 characters). The entitlement must be defined for the app. Repeating the same grant changes nothing; a new `expiresAt` replaces the old one. Returns `grant`, `changed` and the resulting `customerInfo`. See [promotional grants](https://apprefer.com/docs/billing.md#promotional-grants). | | `DELETE /apps/{appId}/billing/customers/{appUserId}/entitlements/{entitlementId}` | billing:write | Revoke that grant at once (optional body `{ reason }`). Purchases are untouched. `404` when there is no grant; repeating it changes nothing. | | `GET /apps/{appId}/billing/experiments/{experimentId}/results` | billing:read | Per-variant results of an offering experiment. | | `GET /apps/{appId}/billing/readiness` | analytics, apps, billing or integrations read | For release checks: mode, connected stores and their public ids, which secrets are set (never their values), when each store last sent a notification, catalogue counts. | | `GET /apps/{appId}/subscriptions/metrics?start=&end=&bucket=&store=&sandbox=` | apps:read | Revenue, trials, conversions, churn, active subscriptions and MRR. Dates `YYYY-MM-DD` (UTC, inclusive, default the last 30 days, up to 731 days); `bucket` day, week or month; `store` all, app\_store, play\_store or stripe; `sandbox=true` for test purchases. | **Is this user premium?** ```bash curl "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/billing/customers/user_8421" \ -H "Authorization: Bearer ak_live_..." { "data": { "appUserId": "user_8421", "entitlements": { "premium": { "id": "premium", "isActive": true, "productId": "com.example.app.annual", "store": "app_store", "expiresAtMs": 1792000000000, "willRenew": true, "periodType": "normal", "state": "active", "subscriptionKey": "2000000123456789" } }, "activeSubscriptions": ["com.example.app.annual"], "requestDateMs": 1760870400000 } } ``` Set-up, store notifications and the outbound webhook are covered in [Billing](https://apprefer.com/docs/billing.md); the catalogue and experiments in [Offerings](https://apprefer.com/docs/offerings.md); metric definitions in [Subscription analytics](https://apprefer.com/docs/subscriptions.md). ### Website conversions | Endpoint | Scope | What it does | | --- | --- | --- | | `POST /apps/{appId}/web-events` | apps:write | Verified website receipts from your backend: `purchase`, `renewal`, `refund`, and the funnel steps `sign_up`, `checkout_started` and `start_trial`. Needs `webEvents.enabled` on the app (otherwise `503 web_events_disabled`). Unknown fields are refused. `202` means durably accepted; a retry with the same `eventId` is idempotent, different data is `409`. | | `POST /apps/{appId}/web-purchases` | apps:write | The original ChatGPT Ads web-funnel purchase receipt. Same idempotency rules; new integrations should use /web-events. | Payloads and network mapping: [Events](https://apprefer.com/docs/events.md), [ChatGPT Ads](https://apprefer.com/docs/integrations/openai.md) and [X Ads](https://apprefer.com/docs/integrations/x.md). ### End users | Endpoint | Scope | What it does | | --- | --- | --- | | `POST /apps/{appId}/users/erase` | users:erase | Erase one of your app's users when they delete their account. Body: `requestId` (8 to 64 of A-Z, a-z, 0-9, \_ and -), `appUserId`, `deviceIds?` (up to 20), `environment?` (`all` by default, `live` or `sandbox`). Answers `202` for the first call and for every retry with the same requestId; reusing a requestId with different input is `409`. | | `GET /apps/{appId}/users/erase/{requestId}` | users:erase | Status of an erase request: status, warehouse step, counts erased, ignored device ids. | What is erased and what is kept for accounting: [Privacy](https://apprefer.com/docs/privacy.md). ### Support | Endpoint | Scope | What it does | | --- | --- | --- | | `GET /apps/{appId}/support/articles?body=` | apps:read or support:agent | Every help article, drafts and AI-only ones included. body=false leaves the bodies out. | | `PUT /apps/{appId}/support/articles` | apps:write or support:agent | Save, publish, unpublish or remove articles: `{ articles?, publish?, unpublish?, remove? }`, up to 200 changes. | | `GET /apps/{appId}/support/readiness?refresh=` | any read scope or support:agent | Push credentials checked with Apple and Google, email set-up, help-centre counts and the AI mode. refresh=true checks again (at most once a minute). | | `PUT /apps/{appId}/support/customers/{appUserId}/context` | support:context | Replace one source's context snapshot for a customer: `{ environment?, source?, observedAt, staleAfterMinutes?, records }`. Typed records only, up to 50. An older observedAt is `409 STALE_SNAPSHOT`. See [live customer context](https://apprefer.com/docs/support.md#live-customer-context). | | `GET /apps/{appId}/support/customers/{appUserId}/context?environment=` | support:context | What is stored for the customer, per source, with stale flags. | | `DELETE /apps/{appId}/support/customers/{appUserId}/context?environment=&source=` | support:context | Remove one source, or every source. | | `POST /apps/{appId}/support/identity-tokens` | support:context | A single-use identity token for a signed-in user: `{ appUserId, environment?, ttlSeconds? }` (30-600, default 300). The app passes it to `support.verifyIdentity(token)` (SDK 0.12.0+), which verifies its current support session. | | `PUT /apps/{appId}/support/actions` | support:actions | Replace the app's action catalogue: `{ actions: [{ id, label, effect, params?, staff?, ai?, refundUsdParam?, environments?, timeoutSeconds?, identityMaxAgeMinutes?, enabled? }] }`, up to 20. See [resolution actions](https://apprefer.com/docs/support.md#resolution-actions). | | `GET /apps/{appId}/support/actions` | support:actions | The registered actions (with their version) and when your backend last claimed work. | | `POST /apps/{appId}/support/actions/claim` | support:actions | Claim waiting runs: `{ environment?, limit? 1-10 }`, each for 2 minutes. Re-delivered with the same `executionId` if no result arrives: apply each at most once. | | `POST /apps/{appId}/support/actions/runs/{executionId}/complete` | support:actions | Report a result: `{ leaseId, status: succeeded|failed, code?, message?, data? }`. The same result again is a no-op; a different one is `409 ALREADY_COMPLETED`. | | `PUT /apps/{appId}/support/testimonials/campaigns` | support:testimonials | Replace the app's photo/video campaigns (at most 10): what to ask, media, reward, public-use permission text, frequency, AI and automation. Each stays off until a company admin switches it on in Support. | | `GET /apps/{appId}/support/testimonials/campaigns` | support:testimonials | The campaigns with their version and on/off state, and when your backend last claimed rewards. | | `POST /apps/{appId}/support/testimonials/requests` | support:testimonials | Ask one user (from your screens or your coach tool): `{ campaignId, appUserId, environment?, source?: host|coach, idempotencyKey }`. Returns `requestId` for the SDK's `testimonials.present`. The reward comes from the campaign. | | `GET /apps/{appId}/support/testimonials/requests/{requestId}` | support:testimonials | A request's status, media summary, publication permission and reward state. | | `POST /apps/{appId}/support/testimonials/rewards/claim` | support:testimonials | Claim earned rewards (`{ environment?, limit? 1-10 }`, 2-minute leases). Apply each `fulfillmentId` at most once. | | `POST /apps/{appId}/support/testimonials/rewards/{fulfillmentId}/complete` | support:testimonials | Report a reward: `{ leaseId, status: fulfilled|failed, receiptId?, code?, message? }`; fulfilled needs your receiptId. | | `GET /apps/{appId}/support/testimonials/library?environment=&limit=` | support:testimonials | Approved media with public-use permission as a manifest: scopes, whether rewarded, 10-minute download links. No customer identifiers. | | `GET /apps/{appId}/support/actions/runs/{executionId}` | support:actions | A run's status, attempts and result. | | `GET /support/agent/queue?limit=` | support:agent | Claim conversations waiting for an AI answer (limit 1 to 20, default 5), each for 5 minutes. | | `POST /support/agent/conversations/{id}` | support:agent | Post the agent's answer. Returns what AppRefer did with it: send, schedule, draft or none. | | `GET /support/agent/knowledge?appId=&sandbox=` | support:agent | Published help articles and your team's recent answers, personal details masked. | | `PUT /support/agent/knowledge` | support:agent | Add or replace articles for one app: `{ appId, articles, publish?, unpublish?, remove? }`. New articles are AI-only unless they say `audience: "public"`. | Field-by-field details: [Support](https://apprefer.com/docs/support.md). ### SKAdNetwork and AdAttributionKit | Endpoint | Scope | What it does | | --- | --- | --- | | `GET /apps/{appId}/skan` | skan:read | The conversion-value table saved in the dashboard (`{ rows }`). It is for reference: the SDKs use AppRefer's built-in conversion schema. | | `PUT /apps/{appId}/skan` | skan:write | Save that table: `{ rows: [...] }`. | | `GET /apps/{appId}/skan/postbacks?days=` | skan:read | Summary of Apple's verified postbacks for the app (days 1 to 400, default 30). | See [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md). ## Calling the API **curl** ```bash curl "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/campaigns?days=30&groupBy=adgroup" \ -H "Authorization: Bearer $APPREFER_API_KEY" ``` **Node** ```typescript const res = await fetch( "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/campaigns?days=30&groupBy=adgroup", { headers: { Authorization: `Bearer ${process.env.APPREFER_API_KEY}` } }, ); if (!res.ok) throw new Error((await res.json()).error?.message); const { data } = await res.json(); console.log(data.totals, data.campaigns); ``` **Python** ```python import os, requests res = requests.get( "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/campaigns", headers={"Authorization": f"Bearer {os.environ['APPREFER_API_KEY']}"}, params={"days": 30, "groupBy": "adgroup"}, ) res.raise_for_status() print(res.json()["data"]["totals"]) ``` > **OpenAPI and agents** > > An OpenAPI file for the core endpoints (apps, links, integrations, analytics and SKAN) is at `/openapi.yaml`, with an interactive viewer at `/api/v1/docs`; this page is the complete list. To let a coding agent work with AppRefer, see [AI agents and MCP](https://apprefer.com/docs/mcp.md). ## Self-hosted operator endpoints `/api/v1/admin/*` (organisations, their apps and stats, one app and its links) accept only the deployment's platform admin key (`ak_admin_`, the `APPREFER_ADMIN_KEY` set on the server), never an organisation key. The same admin key is also accepted by the billing configuration, readiness and help article endpoints above. They exist for whoever runs an AppRefer deployment. --- Source: https://apprefer.com/docs/api · Last updated: 2026-10-19 > 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). # Privacy and data handling AppRefer tracks only with the user's permission, hashes personal data on the device, keeps test data separate and lets your server erase a user when they delete their account. ## Tracking consent The SDKs never show the App Tracking Transparency prompt. From SDK 0.6.0 they read the status your app already has (on Android, the consent you pass with `setTrackingConsent()`) and send it with the install. Matching an install to an ad click, sending conversions to ad networks and keeping advanced-matching data happen only when the user allowed tracking. Without permission an install gets Apple AdServices or organic attribution, and analytics, AppRefer Billing and Support keep working. The server checks the stored consent again on every forward and retry. > **Installs that sent no consent** > > Installs from SDKs older than 0.6.0 send no consent status, and AppRefer treats those as allowed so existing apps keep their attribution. To treat a missing status as “not allowed”, set `trackingConsentRequired: true` on the app with `PATCH /api/v1/apps/{appId}` once every live version of your app is on 0.6.0 or later (or if older installs must never be tracked). See [Tracking consent](https://apprefer.com/docs/attribution.md#tracking-consent). Requests from installs that allowed tracking go to `trk.apprefer.com`; everything else goes to `apprefer.com`, and the two never fall back to each other. In your iOS privacy manifest, declare only `trk.apprefer.com` in `NSPrivacyTrackingDomains`. How undecided installs are held, and the `trackingConsentRequired` and `probabilisticMatching` app settings, are described under [Attribution](https://apprefer.com/docs/attribution.md#tracking-consent). ## Turning collection off If your app has its own privacy switch, call `setCollectionEnabled(false)` (SDK 0.9.1+). The choice is saved on the device. It stops attribution, consent reports, conversion-value updates and product events, and clears events waiting to be sent. AppRefer Billing and Support keep working, and Billing stops sending the install's device ID. To resume, call `setCollectionEnabled(true)` and then `configure()`. **Swift** ```swift await AppRefer.setCollectionEnabled(false) ``` **Kotlin** ```kotlin AppRefer.setCollectionEnabled(false) ``` **Dart** ```dart await AppReferSDK.setCollectionEnabled(false); ``` **TypeScript** ```typescript await AppRefer.setCollectionEnabled(false); ``` When a user signs out, call `reset()` (SDK 0.9.0+). It clears the user's ID, queued events and Support session on the device and keeps the install's own attribution. Nothing more is sent until you call `configure()` for the next user. ## On-device hashing Personal data passed to `setAdvancedMatching()` is SHA-256 hashed on the device, so AppRefer never receives it in plain text. The SDK only sends it for installs that allowed tracking. | Field | Example input | What is hashed | | --- | --- | --- | | Email | User@Example.com | Trimmed and lower-cased: user@example.com | | Phone | +1 (415) 555-1234 | Digits only: 14155551234 | | First name | John | Trimmed and lower-cased: john | | Last name | Doe | Trimmed and lower-cased: doe | | Date of birth | 19900115 | Trimmed; pass it as YYYYMMDD | These hashes are attached to conversions for Meta and TikTok, which expect hashed values. Email, phone and name details that arrive with a RevenueCat webhook are hashed on the server in the same way, and are kept only for installs that allowed tracking. ## Data retention | Data | Kept for | Purpose | | --- | --- | --- | | Clicks (including IP address) | 30 days | Matching clicks to installs | | Attributions | 400 days | Install-to-campaign mapping, long enough to cover yearly renewals | | Forwarding retry queue | 30 days | Retrying failed sends to ad networks | | Webhook de-duplication | 30 days | Ignoring repeated RevenueCat events | | Held trials | 30 days | Qualified trials | | Qualified purchase results and store notices | 400 days | Qualified purchases (observe only); ids stored as one-way hashes | | Qualified purchase activity record | Up to 45 days | Counted SDK event ids and times per install, only for event names a rule counts; deleted when the user is erased | | SKAdNetwork postbacks | 90 days | Postback validation | | Audit log | 365 days | Who changed what | | Billing events and store links | 400 days | Purchase idempotency and linking old app versions | | Support sessions | 400 days after last use | In-app chat identity | | Support screenshots | 400 days (unsent uploads: 1 day) | Attachments in conversations | Records expire automatically. Aggregated analytics (installs, events, revenue) are kept in the reporting warehouse for long-term dashboards. ## Test data stays separate Data sent with a test key (`pk_test_`) and store sandbox purchases are kept apart from live data: - They are never sent to ad networks. - They don't appear in live dashboard views or revenue; use the **Sandbox** switch to see them. - Installs are matched separately from live installs. - Support conversations from test keys sit in a separate test inbox. App Review buys with sandbox accounts in your release build, so a sandbox purchase sent with your live key still unlocks the purchase (AppRefer Billing) but is recorded as sandbox and never counted or forwarded. ## SDK dependencies No AppRefer SDK bundles another analytics or advertising SDK. The iOS SDK has no third-party dependencies. The others use only standard platform libraries: - **Android:** Kotlin coroutines, Google Play Install Referrer and the Google Play services advertising ID library. - **Flutter:** `shared_preferences`, `http`, `crypto`, `device_info_plus`, `package_info_plus`, `intl` and the official `in_app_purchase` plugins. - **React Native:** AsyncStorage and `react-native-iap` as peer dependencies. ## Erasing a user (account deletion) When someone deletes their account in your app, your server calls `POST /api/v1/apps/{appId}/users/erase` with an organisation API key that has the `users:erase` scope. ```bash curl -X POST https://apprefer.com/api/v1/apps/APP_ID/users/erase \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{ "requestId": "del_8f3a2c91", "appUserId": "user_123" }' ``` | Field | Required | Description | | --- | --- | --- | | `requestId` | Yes | Your deletion job ID, 8 to 64 letters, digits, \_ or -. Retrying with it is safe. | | `appUserId` | Yes | The user ID you set with setUserId and use for purchases. | | `deviceIds` | No | Up to 20 device IDs. Each counts only if AppRefer already links it to this user. | | `environment` | No | `all` (default), `live` or `sandbox` | AppRefer finds the user's devices itself, from `setUserId` and their events. It removes their attribution records, queued conversions, held trials, support conversations, the support context your backend sent and AppRefer Billing identity straight away and answers 202. Rows in the reporting warehouse follow within a few hours, once recent rows can be changed. Check progress with `GET /api/v1/apps/{appId}/users/erase/{requestId}`. - **Analytics events** become anonymous counts: no user, device or session ID, no properties, and the time cut to the day, so they no longer link to each other or to anyone. - **Accounting records** (payments, refunds, renewals, trial starts and the billing ledger) keep their amounts, dates and products, with the user replaced by a keyed one-way stand-in. They are never listed in the dashboard or API, and are deleted seven years after their date, which covers the six years after the end of the financial year that tax record-keeping rules require. - App settings, links and integrations are never touched. Conversions already sent to ad networks can't be recalled. - Reusing a `requestId` for a different user returns 409. 30 days after the job finished AppRefer forgets the request and returns 409 `REQUEST_ID_EXPIRED`. ### Live and sandbox data By default one call erases the user's live and sandbox data together. Send `"environment": "sandbox"` to erase only test data (installs and support chats made with a test key, App Store and Google Play sandbox purchases), for example to clear your testers before launch, or `"live"` for the reverse. The value is returned on the job; anything else is a 400, and retrying a `requestId` with a different environment is a 409. A sandbox-only erase never touches live data, so it keeps the records AppRefer holds once per user for both sides (the Billing customer record, account tokens and store links). Use the default if you want those gone too. > **Account deletion must use the default** > > In-app account deletion must use `all`. `live` leaves the user's sandbox purchases, test installs and test-key chats tied to them. Use a separate `requestId` per environment (for example an `sb_` prefix for tester clean-ups): a `requestId` used once with `sandbox` can't later be reused for the full deletion. > **Your data, secured** > > Attribution data and ad network credentials are encrypted in transit and at rest, and integration secrets are never shown again after you save them. Your data is only sent to the ad networks and services you connect. --- Source: https://apprefer.com/docs/privacy · Last updated: 2026-10-27