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, Android, Flutter and React Native 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.
dependencies:
apprefer:
git:
url: https://github.com/AppAgentic/apprefer-flutter-sdk.git
ref: 0.13.1import 'package:apprefer/apprefer.dart';Pre-1.0 versions
Before 1.0, a minor version can change behaviour. Keep theref pinned to a tag and read the changelog highlights below before upgrading.Platform setup
- iOS: iOS 15 or later (run
pod installafter upgrading). In your privacy manifest (PrivacyInfo.xcprivacy), declare onlytrk.apprefer.comunderNSPrivacyTrackingDomains, neverapprefer.com. For postback copies, add the Info.plist keys in SKAdNetwork and AdAttributionKit. - Android: nothing to add. The plugin brings the Play Install Referrer and Advertising ID libraries and declares
INTERNETandcom.google.android.gms.permission.AD_ID. The Advertising ID is read only when tracking consent is granted (or not set); remove the permission withtools:node="remove"if your app shouldn't declare it.
<key>NSPrivacyTracking</key>
<true/>
<key>NSPrivacyTrackingDomains</key>
<array>
<string>trk.apprefer.com</string>
</array>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.
import 'package:apprefer/apprefer.dart';
Future<void> 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<Attribution?> 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.
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) |
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<String, dynamic> | Extra tracking-link parameters, when present |
customData | Map<String, dynamic> | 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.
- 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 everyconfigureand 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.
// 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.
await AppReferSDK.setUserId(currentUser.id);
await AppReferBilling.logIn(currentUser.id); // if you use AppRefer BillingAdvanced 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.
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).
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.
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<String, dynamic>? | 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.
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.
// 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 callconfigureagain. Events tracked in between are dropped, never sent later. - Billing logout stays explicit. Until Billing's
logInorlogOutruns, 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 beforeconfigureand 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.
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.
<key>NSAdvertisingAttributionReportEndpoint</key>
<string>https://apprefer.com</string>
<key>AttributionCopyEndpoint</key>
<string>https://apprefer.com</string>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 for the server setup and the move from RevenueCat.
Set up
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:<UUID>"
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 callcompletePurchase 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.
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.currentis the resolved default andofferings.allholds every offering by id. Each offering hasavailablePackages,metadata, a package lookup and the shortcutsweekly,monthly,twoMonth,threeMonth,sixMonth,annualandlifetime.- An unknown placement falls back to the current offering; a placement set to no offering returns
null(show no paywall). - Buying through
purchasePackagerecords 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
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 purchasepurchasetags the purchase with the customer's AppRefer account token (AppleappAccountToken, GoogleobfuscatedAccountId), sends it to AppRefer and only then completes it. On Google Play a subscription returns oneProductDetailsper base plan or offer.- Offers:
promotionalOffer: SK2PromotionalOffer(...)orwinBackOfferId: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,
purchasethrows 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
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
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, setAttributesThe 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.
Open the chat
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'), .homeIdentify and customise
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 "<App name> 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.
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 badgeOn 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.
await AppReferSDK.support.openHelpCentre(context: context);
await AppReferSDK.support.openArticle('restore-purchases', context: context);
final articles = await AppReferSDK.support.articles(); // List<AppReferHelpArticle>
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
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 articlesstatus() 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.
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.