Encore Flutter SDK
Flutter plugin wrapping the native Encore iOS and Android SDKs. All offer UI is rendered natively — this plugin bridges configuration, identity, placement presentation, purchases, and outcome reporting via platform channels.
Installation
Add to your pubspec.yaml:
dependencies:
encore_flutter: ^2.0.0
iOS
The EncoreKit CocoaPod is automatically included as a transitive dependency of the Flutter plugin — no manual pod configuration is needed.
Minimum deployment target: iOS 15.0.
Android
The com.encorekit:encore AAR is included as a transitive dependency of the Flutter plugin — no manual Gradle configuration is needed.
Minimum SDK: 26.
Usage
Configure
Call once early in your app lifecycle (e.g. in main()):
import 'package:encore_flutter/encore_flutter.dart';
await Encore.shared.configure(
apiKey: 'your_api_key',
purchaseController: AppPurchases(), // see below
logLevel: EncoreLogLevel.debug,
);
transactionLinking defaults to true. Pass false to stop the SDK from
reading the store's purchases (StoreKit history on iOS, Play's active
purchases on Android) and from sending their transaction ids or purchase
tokens to Encore. Encore-attributed claims and purchases still report, but
lift against a control group cannot be measured for your app.
app_account_id still accompanies analytics events.
Run purchases
Encore controls when a purchase happens; your app owns how. Implement
EncorePurchaseController and register the instance at configure — this is
the only purchase path, and the SDK never runs purchase code you did not write.
With no controller registered nothing is ever charged, and a presentation whose
layout offers a product resolves EncorePublisherOutcome.notAttempted.
class AppPurchases implements EncorePurchaseController {
@override
Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request) async {
// request.productId / .placementId / .promoOfferId (iOS) / .basePlanId (Android)
try {
await mySubscriptionManager.purchase(request.productId);
return EncorePurchaseResult.purchased;
} on UserCancelled {
return EncorePurchaseResult.cancelled;
} on PurchaseDeferred {
return EncorePurchaseResult.pending; // Ask to Buy / SCA
}
// Throw for a real failure — Encore records it and resolves the
// presentation with EncorePublisherOutcome.failed.
}
}
The result is three-valued on purpose. pending is a deferred purchase (Ask to
Buy, SCA, a pending Play transaction): the user has not abandoned the flow and
has not been charged yet, so neither purchased nor cancelled is true, and
the store's eventual webhook is the source of truth.
Registration can only happen at configure, because both native SDKs bind the
controller once and expose no setter. It survives reset() — it is app-level
infrastructure, not user state.
Identify User
After authentication:
await Encore.shared.identify(
userId: 'user_123',
attributes: EncoreUserAttributes(
email: 'user@example.com',
subscriptionTier: 'premium',
),
);
Update Attributes
await Encore.shared.setUserAttributes(
EncoreUserAttributes(billingCycle: 'annual'),
);
Language
You don't need to do anything for Encore's copy to follow the user's language. The SDK sends the device language with every request, and Encore serves the copy in that language when your app has it enabled. Anything else falls back to your app's default language.
If your app has its own language picker, pass the choice to setLanguage(). It
outranks the device language. It is a device preference, not user data, so it
stays across launches and through reset() (logout) until you clear it:
await Encore.shared.setLanguage('pt-BR');
// From a Flutter Locale, use toLanguageTag(): toString() gives 'pt_BR'.
await Encore.shared.setLanguage(locale.toLanguageTag());
setLanguage() takes a BCP 47 tag (fr, pt-BR, zh-Hant-TW); for anything
that is not one, the returned future fails with an ArgumentError. Only the language part is used
(pt-BR picks Portuguese), and only when your app has that language enabled;
otherwise the device language applies, then your app's default.
When the user goes back to "System default", call clearLanguage():
await Encore.shared.clearLanguage();
clearLanguage() keeps the user and every attribute, and refetches the copy
for the device language. It does nothing if no language was set. Text you pass
to .headline() or .subheadline() is shown exactly as you give it, so
translate it yourself.
The language field on EncoreUserAttributes is deprecated. It still works
(the SDK applies it as setLanguage() would) and is removed in the next major
version. Replace EncoreUserAttributes(language: tag) with
Encore.shared.setLanguage(tag).
Present Offers
final result = await Encore.placement('cancel_flow').show();
switch (result) {
case EncoreNotPresented(:final reason):
print('Nothing shown: ${reason.name}');
case EncorePresented(:final outcome):
print('advertiser=${outcome.advertiser} '
'publisher=${outcome.publisher.name} '
'dismissal=${outcome.dismissal.name}');
}
show() never throws. Either nothing appeared — EncoreNotPresented, with a
reason — or the interaction ran and EncorePresented carries the complete
factual record.
That record has two independent funnels plus how the sheet ended:
| Axis | Type | What it records |
|---|---|---|
advertiser |
EncoreAdvertiserOutcome |
How far the Encore offer claim got: notAttempted / claimed / verified / cooldown / failed |
publisher |
EncorePublisherOutcome |
What your purchase controller reported: notAttempted / purchased / cancelled / pending / failed |
dismissal |
EncoreDismissReason |
How the sheet went away |
The two funnels are independent — a single presentation can claim an offer and
run a purchase. notAttempted is a real value ("funnel open, nothing entered
it"), which is a different fact from null ("never presented at all").
There is deliberately no SDK-computed "unlocked" verdict: what a claim means is a property of the variant flow that served it, so any projection over app-global config could contradict the flow that actually ran. Branch on the raw axes:
final converted = result.claim != null ||
result.publisher == EncorePublisherOutcome.purchased;
result.claim reads through both claimed and verified and gives you the
EncoreClaimedOffer — campaignId, advertiserName, and a nullable
transactionId that joins this claim to the completion that lands days later
(the same id the offer-completed webhook and the server-side
sdk_offer_completed event carry). It is nullable because the transaction write
can fail while the claim still happened; the claim is reported either way.
To give a reward-for-trying prize, check
result.claim?.userConfirmedCompletion == true (see below).
Observe outcomes
Encore.shared.outcomes.listen((outcome) {
switch (outcome) {
case EncorePlacementPresentation(:final placementId, :final result):
analytics.log('encore_presentation', placementId, result);
case EncoreStrictUnlockVerified(:final transactionId):
entitlements.refresh(transactionId);
}
});
A broadcast Stream carrying every show() resolution — including the ones
that presented nothing — plus strict-unlock verifications.
This is the observation channel; control flow belongs at the call site. Its
reason for existing is EncoreStrictUnlockVerified, which resolves after the
flow that produced it ended, possibly on a later launch, and so can never be a
show() return value. It only fires under EncoreUnlockMode.strict. There is
no replay — subscribe at startup if you want every outcome.
Use cases and copy
To present the reward surface instead of the monetization sheet — a brand-funded reward at a moment the user has just accomplished something — set the use case and supply your own copy:
final result = await Encore.placement('streak_complete')
.useCase(EncoreUseCase.rewardUsers)
.headline('7 day streak!')
.subheadline("Here's a little thank you from us")
.show();
useCase defaults to EncoreUseCase.reduceChurn, so existing calls are
unchanged. EncoreUseCase.rewardUsers is claim-only and never presents an
in-app purchase.
Copy resolves in strict priority: the value you pass here, then the value configured in the Encore portal, then the shipped template default. A blank string is ignored, so the chain falls through rather than rendering an empty line.
headline / subheadline apply to both use cases — the native side writes
them into the variable the active template reads.
If a use case resolves no layout of its own, the result is
EncoreNotPresented(reason: EncoreNotPresentedReason.useCaseUnavailable) —
typically because the use case is not enabled for this app. It is a correct
no-op, not an error, and it never falls back to the monetization sheet:
putting an IAP screen in front of a user at a moment they were meant to be
rewarded is worse than showing nothing.
Reward for trying
Describe your own prize on the placement. On a reward-for-trying variant the
sheet promises it up front and, when the user comes back from the advertiser,
asks whether they finished. If they tap "Yes, I completed it" and Encore's
server grants the prize, the claim on the result has
userConfirmedCompletion == true, and that is when you grant it.
identify() is required: the server keeps the count per identified user,
across devices. A user you have not identified, including one after reset(),
still gets the question but never the promise or the flag.
// Once, when the user signs in.
await Encore.shared.identify(userId: user.id);
final result = await Encore.placement('level_complete')
.useCase(EncoreUseCase.rewardUsers)
.publisherReward(const EncorePublisherReward(
id: 'coins_100', // only this leaves the device
title: '100 coins',
detail: 'Added to your wallet', // optional
iconUrl: 'https://example.com/coin.png', // optional, http/https
))
.show();
// Encore's own reward: the claim went through.
final claimed = result.advertiser is EncoreAdvertiserClaimed ||
result.advertiser is EncoreAdvertiserVerified;
// Your prize: the user said they finished and the server granted it.
final claim = result.claim;
if (claim != null && claim.userConfirmedCompletion) {
// transactionId can be null, so dedupe on your user id and the campaign.
wallet.add(100, dedupeKey: '${user.id}:${claim.campaignId}');
}
userConfirmedCompletion is the user's own word, not a verified conversion. It
does not change the advertiser stage (a claimed offer stays claimed) and it
never reaches Encore's payout. Passing a prize commits your app to paying it
when the flag is true. Every other variant ignores the prize, so it is safe to
pass on any reward placement.
The same claim, flag included, also arrives on Encore.shared.outcomes.
Grant the prize from the show() result only, and use the stream for
analytics. Granting from both pays twice.
The server decides the prize. Every "Yes" is sent to Encore, and the first
"Yes" per user per offer earns it, within a daily limit (default 3 prizes per
user in any 24 hours, clamped to 1 to 10). After a "Yes" the server hides that
offer from the user on every device, so the promise never shows on an offer
they already earned. Once the daily limit is reached the sheet asks without a
prize and the flag is false. To change the daily limit:
await Encore.shared.setPublisherRewardPolicy(
const EncorePublisherRewardPolicy(dailyLimit: 5),
);
Claim button
await Encore.shared.setClaimEnabled(false); // gray out and disable
Reset (Logout)
await Encore.shared.reset();
Migrating from 1.x
2.0 tracks the native Encore 2.0 SDKs, which removed onPurchaseRequest
entirely and replaced it with a registered purchase controller. There is no
compatibility shim, because the only shim available would have had to collapse
the three-valued purchase result back into 1.x's void/bool — which is
exactly the bug 2.0 fixes.
| 1.x | 2.0 |
|---|---|
Encore.shared.onPurchaseRequest(handler) |
Implement EncorePurchaseController, pass it to configure(purchaseController:) |
Encore.shared.onPurchaseRequestResult(handler) |
Same — and return pending instead of false for deferred purchases |
Encore.shared.onPurchaseComplete(handler) |
Removed. The SDK no longer runs purchases itself, so there is no native purchase to report — your controller already sees every purchase it runs |
Encore.shared.onPassthrough(handler) |
Removed. Read the result instead: EncoreNotPresented means nothing was shown, and a presented record carries the dismissal and both funnels |
Encore.shared.placements.setClaimEnabled(x) |
Encore.shared.setClaimEnabled(x) |
EncorePresentationResultGranted |
EncorePresented + result.claim != null || result.publisher == EncorePublisherOutcome.purchased |
EncorePresentationResultClaimed |
EncorePresented + result.claim != null |
EncorePresentationResultNotGranted(reason: String) |
EncoreNotPresented(reason: EncoreNotPresentedReason) for "nothing shown", or EncorePresented + outcome.dismissal for "shown, then dismissed" — 1.x conflated the two |
EncoreBillingPurchaseResult |
Removed with onPurchaseComplete |
'use_case_unsupported' |
Gone. 1.x dropped the use case at the bridge, so rewardUsers could never present; 2.0 forwards it, leaving EncoreNotPresentedReason.useCaseUnavailable as the only unavailability reason |
Before:
await Encore.shared.configure(apiKey: 'key', logLevel: EncoreLogLevel.debug);
Encore.shared.onPurchaseRequestResult((request) async {
return await mySubscriptionManager.purchase(request.productId);
});
Encore.shared.onPassthrough((placementId) => runOriginalAction());
final result = await Encore.placement('cancel_flow').show();
if (result is EncorePresentationResultGranted) grantAccess();
After:
class AppPurchases implements EncorePurchaseController {
@override
Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request) async {
return await mySubscriptionManager.purchase(request.productId)
? EncorePurchaseResult.purchased
: EncorePurchaseResult.cancelled; // or .pending, when deferred
}
}
await Encore.shared.configure(
apiKey: 'key',
purchaseController: AppPurchases(),
logLevel: EncoreLogLevel.debug,
);
final result = await Encore.placement('cancel_flow').show();
if (result.claim != null ||
result.publisher == EncorePublisherOutcome.purchased) {
grantAccess();
} else {
runOriginalAction(); // what onPassthrough used to do
}
Architecture
Flutter App
│
▼
┌──────────────────────────────────────┐
│ Encore Dart API │
│ (lib/src/encore.dart) │
├──────────────────┬───────────────────┤
│ MethodChannel │ EventChannel │
│ com.encorekit/ │ com.encorekit/ │
│ encore │ encore/outcomes │
├──────────────────┼───────────────────┤
│ iOS Plugin │ Android Plugin │
│ (Swift) │ (Kotlin) │
├──────────────────┼───────────────────┤
│ EncoreKit │ com.encorekit │
│ CocoaPod │ :encore AAR │
└──────────────────┴───────────────────┘
The method channel carries request/response calls in both directions: the purchase controller is a reverse call, where native asks Dart to run the purchase and suspends until Dart answers. Dart cannot implement a Swift protocol or a Kotlin interface, so each plugin owns the native conformance and forwards over the channel.
Android's native controller is additionally handed the foreground Activity;
the plugin absorbs it — it is valid only for the duration of the call and must
not be retained, and iOS has no equivalent — so the Dart contract is identical
on both platforms.
The event channel carries the outcomes stream, which is one-way and unbounded
and so cannot ride a request/response method call. It bridges an AsyncStream
on iOS and a SharedFlow on Android onto one Dart broadcast Stream.
API Reference
| Method | Description |
|---|---|
Encore.shared.configure(apiKey:, purchaseController:, logLevel:, unlock:, transactionLinking:) |
Initialize the SDK and register the purchase controller |
Encore.shared.identify(userId:, attributes:) |
Associate user identity |
Encore.shared.setUserAttributes(attributes) |
Merge user attributes |
Encore.shared.setLanguage(tag) |
Show Encore's copy in the app-chosen language (a BCP 47 tag) instead of the device language |
Encore.shared.clearLanguage() |
Remove the language set by setLanguage so copy follows the device language again |
Encore.shared.reset() |
Clear user data (logout); the purchase controller and the language set by setLanguage survive |
Encore.shared.setClaimEnabled(enabled) |
Enable/disable the claim CTA |
Encore.shared.setPublisherRewardPolicy(policy) |
Daily cap on the reward-for-trying prize (default 3, clamped to 1 to 10) |
Encore.shared.outcomes |
Stream<EncorePlacementOutcome> of every resolved outcome |
Encore.placement(id).show() |
Present the native offer sheet; returns EncorePresentationResult |
Encore.placement(id).useCase(useCase) |
Select what the placement should achieve: EncoreUseCase.reduceChurn (default) or .rewardUsers |
Encore.placement(id).headline(text) |
Override the sheet headline, on every use case |
Encore.placement(id).subheadline(text) |
Override the sheet subheadline, on every use case |
Encore.placement(id).publisherReward(reward) |
Your own prize for trying an offer; grant it when result.claim?.userConfirmedCompletion == true |
Libraries
- encore_flutter
- Flutter plugin wrapping the native Encore iOS and Android SDKs.