BillDog Flutter SDK
Monetization, paywalls, and subscription management for Flutter apps. A thin bridge over the native BillDog iOS and Android SDKs: hosted paywalls render natively, and headless reads return the offering for your own UI.
Installation
dependencies:
billdog_flutter: ^1.0.2-beta.3
flutter pub get
iOS: set the deployment target to 15.0
The plugin requires iOS 15.0, but Flutter's default app template targets 13.0.
Without this step the iOS build fails at pod install with:
Error: The plugin "billdog_flutter" requires a higher minimum iOS deployment
version than your application is targeting.
Uncomment (or add) the platform line at the top of ios/Podfile:
platform :ios, '15.0'
then run cd ios && pod install. Also set iOS Deployment Target to 15.0 for
the Runner target in Xcode, so the app and the pods agree.
On Flutter 3.24+ with Swift Package Manager enabled (the default in recent versions), the plugin resolves through SPM and no
Podfileis generated — but the 15.0 minimum still applies to theRunnertarget.
Quick Start
import 'package:billdog_flutter/billdog_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await BillDog.configure(
'bd_live_your_api_key',
options: BillDogOptions(enableLogging: true),
);
runApp(MyApp());
}
Presenting a Paywall
Via Placement (Recommended)
// Present a paywall configured for a placement
BillDog.shared.registerPlacement(
'onboarding_end',
handler: PaywallPresentationHandler(
onPresent: (info) => print('Paywall shown: ${info.identifier}'),
onDismiss: (info) => print('Paywall dismissed'),
onSkip: () => print('No paywall for this placement'),
),
);
Inline Widget
PaywallView(
identifier: 'premium_offer',
onPurchase: (productId, data) {
print('Purchase requested: $productId');
},
onDismiss: () => Navigator.pop(context),
loadingWidget: Center(child: CircularProgressIndicator()),
)
Headless: your own paywall UI
Ask the server what to sell, draw it yourself, and buy through BillDog. No paywall is downloaded or presented.
final result = await BillDog.shared.getOffering('onboarding');
switch (result) {
case OfferingAvailable(:final offering):
showMyPaywall(offering);
case OfferingHoldout():
// This user is in the holdout group: show no paywall.
continueWithoutPaywall();
case OfferingWithheld(:final gated):
// The placement's content was withheld from this user. If the placement
// gates a feature (`gated`), keep the feature closed.
gated ? keepFeatureClosed() : continueWithoutPaywall();
case OfferingNotConfigured():
continueWithoutPaywall();
case OfferingFailed(:final error):
report(error);
}
getOffering and getOfferings are instance methods (BillDog.shared). They
never throw, and never hand you a different offering in place of an answer they
did not get.
Every outcome carries what the server said about the placement: placementInfo
(id, display name, description, metadata), presentation (gated, holdout,
withheld, withheldReason), experiment (experimentId, variantId,
variantName) and campaign (id, name, audience). A holdout still names its
experiment, so you can log the arm you did not show.
if (result.experiment case final experiment?) {
analytics.log('arm', experiment.variantName ?? experiment.variantId);
}
Typed failures
Every server refusal reaches Dart as its own BillDogError, whatever the call:
ProofRequiredError, ProofInvalidError, ReauthRequiredError,
AccessDeniedError (isTargetMismatch), PurchaseOwnerConflictError,
PlacementAccessWithheldError and PlacementNotFoundError, each with its wire
code and the server's reason. Local native failures arrive by category
(NetworkError, StoreError, ConfigurationError, ...). OfferingFailed,
Failed, RestoreFailed, PlacementError, PresentError and
WebCheckoutResult.failure all carry one; getCustomerInfo, getPaywall,
identify and hasAccessForPlacement throw one.
The delegate's onPurchaseFailed(productId, BillDogError error) is typed too,
and onWebCheckoutRedeemed(RedemptionOutcome) reports what became of a web
checkout's return link, including a RedemptionRefused the server would not
credit until the caller has proof.
// The project's default offering, and every offering returned with it.
final result = await BillDog.shared.getOfferings();
// Buy a package (StoreKit / Play Billing through the native SDK).
final purchase = await BillDog.shared.purchasePackage(offering.packages.first);
switch (purchase) {
case Purchased() || Restored(): unlock();
case Cancelled() || Pending(): break;
case Failed(:final error): report(error);
}
// Report your own paywall's impressions. No paywall id is sent.
await BillDog.shared.trackPaywallPresented(offering: offering, placement: 'onboarding');
await BillDog.shared.trackPaywallDismissed(offering: offering, placement: 'onboarding');
On Android purchase buys the product's default offer. The Flutter bridge does
not select a base plan or offer.
Server refusals
A request the server refuses arrives as a typed error (OfferingFailed.error,
Failed.error):
| Error | Wire code | Meaning |
|---|---|---|
ProofRequiredError |
PROOF_REQUIRED |
The request needed a session or device proof and carried none |
ProofInvalidError |
PROOF_INVALID |
The proof was malformed, expired or unknown |
ReauthRequiredError |
REAUTH_REQUIRED |
The sign-in is too old for a sensitive action; sign in again |
AccessDeniedError |
ACCESS_DENIED |
isTargetMismatch: the request named another customer |
PurchaseOwnerConflictError |
PURCHASE_OWNER_CONFLICT |
The store purchase belongs to another customer |
PlacementNotFoundError |
PLACEMENT_NOT_FOUND |
No placement has this identifier (usually a typo) |
The device proof is created, stored and sent by the native SDKs. Dart never sees the device key.
Web checkout
final result = await BillDog.shared.purchaseViaWeb('com.app.pro.monthly');
// completed: redeemed already (iOS).
// opened: checkout is open in the browser (Android); it finishes on the
// return link.
// Give the app's incoming links to BillDog:
final redeemed = await BillDog.shared.completeWebCheckout(uri); // waits for redemption
// or, without waiting:
final handled = await BillDog.shared.handleDeepLink(uri);
billdog://checkout/success, billdog://checkout/cancel and
billdog://redeem are BillDog's links. The native SDK redeems the token,
refreshes customer info and dismisses the paywall.
Superwall
integrations/billdog_superwall is an optional package: Superwall presents
paywalls, BillDog performs every purchase and restore. billdog_flutter itself
does not depend on Superwall.
dependencies:
billdog_flutter: ^1.0.2-beta.3
superwallkit_flutter: ^2.6.0
billdog_superwall:
git:
url: https://github.com/billdogai/billdog
path: packages/flutter/integrations/billdog_superwall
import 'package:billdog_superwall/billdog_superwall.dart';
import 'package:superwallkit_flutter/superwallkit_flutter.dart' as sw;
await BillDog.configure('bd_public_...');
final controller = SuperwallBillDogPurchaseController();
sw.Superwall.configure('pk_...', purchaseController: controller);
controller.startSyncingSubscriptionStatus();
Forward SuperwallDelegate.handleCustomPaywallAction(name) to
controller.handleCustomPaywallAction(name) to start BillDog web checkout from
a billdog_web_checkout:<productId> paywall action, and the app's incoming
links to controller.handleDeepLink(uri).
Identity Management
// After login
BillDog.shared.identify('user_123');
// Set attributes for targeting
BillDog.shared.setUserAttributes({
'plan': 'free',
'signup_date': '2024-01-15',
});
// On logout
BillDog.shared.reset();
Custom Purchase Handling
If your app buys through its own code (in_app_purchase, RevenueCat, …),
register a PurchaseController. BillDog's paywalls then call it, and BillDog
never finishes or acknowledges a transaction: your code does.
Return the purchase's store evidence from purchase so BillDog records it
at once. Without evidence BillDog derives it from the store, or records the
purchase at its next sweep.
class MyPurchaseController extends PurchaseController {
@override
Set<PurchaseCapability> get capabilities => {PurchaseCapability.upgrades};
// in_app_purchase uses StoreKit 1 unless you opted into StoreKit 2.
@override
HostStoreKitVersion get storeKitVersion => HostStoreKitVersion.storeKit1;
@override
Future<PurchaseOutcome> purchase(PurchaseRequest request) async {
final details = await myStore.buy(
request.productId,
// Optional: binds the store purchase to this customer.
accountToken: request.storeAccountToken,
);
final evidence = StoreEvidence.fromInAppPurchase(details);
return PurchaseOutcome(
Purchased(transactionId: details.purchaseID ?? '', productId: request.productId),
evidence: [if (evidence != null) evidence],
);
}
@override
Future<RestoreOutcome> restore() async {
final restored = await myStore.restore();
return RestoreOutcome(
RestoredItems(restored.map((d) => RestoredItem(productId: d.productID, transactionId: d.purchaseID ?? '')).toList()),
evidence: restored.map(StoreEvidence.fromInAppPurchase).whereType<StoreEvidence>().toList(),
);
}
// The per-store methods are only used when `purchase`/`restore` are not overridden.
@override
Future<PurchaseResult> purchaseFromAppStore(String productId) => throw UnimplementedError();
@override
Future<PurchaseResult> purchaseFromGooglePlay(String productId, {String? basePlanId, String? offerId}) =>
throw UnimplementedError();
@override
Future<RestoreResult> restorePurchases() => throw UnimplementedError();
}
await BillDog.configure(
'bd_live_your_api_key',
purchaseController: MyPurchaseController(),
);
A paywall purchase that needs a capability your controller does not declare (a
quantity above 1, a win-back offer, a Google Play upgrade) fails with a
configuration error instead of buying something else. A purchase your
controller does not answer within five minutes, or one in flight when the
Flutter engine goes away, is reported Pending, never failed: BillDog records
it when the store reports it.
Recording purchases your code makes outside a paywall
// Record it and read what the server did:
final recording = await BillDog.shared.recordPurchase(StoreEvidence.fromInAppPurchase(details)!);
if (recording.status == PurchaseRecordingStatus.rejected) { /* never shown as success */ }
// Or observe it (analytics + recording):
await BillDog.shared.observePurchaseResult(details.productID, evidence: StoreEvidence.fromInAppPurchase(details));
// Backstop sweep, never prompts:
final held = await BillDog.shared.syncStorePurchases();
// Purchases BillDog could not record:
final issues = await BillDog.shared.purchaseRecordingIssues();
StoreEvidence.fromInAppPurchase maps a StoreKit 1 restored transaction to
the original transaction's id, which is the id that names the purchase. You can
also build evidence directly with StoreEvidence.appStore(transactionId: …) or
StoreEvidence.googlePlay(purchaseToken: …, productIds: […]). A Google Play
order id alone is never evidence: pass the purchase token.
Subscription Status
// Check status
if (BillDog.shared.subscriptionStatus.hasAccess) {
// User is subscribed
}
// Manually set (when using custom purchase controller)
BillDog.shared.subscriptionStatus = SubscriptionStatus.active({});
Customer Info
final info = await BillDog.shared.getCustomerInfo();
if (info.hasEntitlement('premium')) {
// Unlock premium features
}
Analytics
BillDog.shared.trackEvent('level_completed', properties: {
'level': 5,
'score': 1200,
});
BillDog Auth
Sign-in, sessions and second factors. The plugin delegates to the native
SDKs rather than reimplementing them, so the refresh token lives in the iOS
Keychain and Android EncryptedSharedPreferences — never in
shared_preferences, which is plain text on disk.
Requires native binaries
1.0.2-beta.3. Auth is absent from the published beta.2 iOS XCFramework (released 2026-08-08, before the module was written), andio.billdog:billdog-authdoes not exist on Maven Central before beta.3.
import 'package:billdog_flutter/billdog_flutter.dart';
final auth = BillDogAuth.instance;
await auth.restore(); // AuthRestoreResult(signedIn: false) on first launch
final r = await auth.attempt(
identifier: 'ada@example.com',
strategy: 'password',
password: password,
);
// Check this BEFORE treating it as a sign-in: against an enrolled account a
// correct password issues no session.
if (r.mfaRequired) {
await auth.attempt(
identifier: 'ada@example.com',
strategy: 'totp',
code: code,
mfaToken: r.mfaToken, // proves the first factor was accepted
);
}
Passwordless, and phone numbers
// A RETURNING user gets a fresh code from requestCode. Without it a code could
// only be obtained as a side effect of signing up.
await auth.requestCode(identifier: '+15551234567', kind: 'phone');
await auth.attempt(
identifier: '+15551234567',
kind: 'phone', // REQUIRED — selects the identity row
strategy: 'sms_code',
code: code,
);
kind defaults to 'email'. A phone sign-in that leaves it at the default
looks a phone number up among email addresses and silently matches nothing.
The rest of the surface
isSignedIn() · signUp(...) · signOut(scope: 'this' | 'others' | 'all') ·
requestPasswordReset(...) · resetPassword(...) · changePassword(...) ·
deleteAccount() ·
passkeysAvailable() · registerPasskey() · signInWithPasskey() ·
BillDogOrganizations — create, list, members, roles, invitations, domains.
Strategies are password, email_code, sms_code, totp, backup_code.
What this plugin cannot do
- Sign up into a project with bot protection or
invite_onlyaccess.signUptakes no captcha or invitation token, and there is noconfig()to discover that one is required — the server answers 400. Use hosted sign-in for those projects until the parameters ship.
Deleting an account
try {
final deleted = await BillDogAuth.instance.deleteAccount();
} on ReauthRequiredError {
// The sign-in is too old for this. Sign the user in again, then retry.
} on ProofRequiredError {
// Nobody is signed in.
}
Architecture
The Flutter SDK follows a thin bridge architecture. The bridge is one
hand-written MethodChannel (io.billdog.flutter/sdk, plus billdog for
analytics and a per-view channel for PaywallView); nothing is generated. Both
plugins answer every method with the same map shapes, and a test
(billdog_channel_parity_test.dart) fails when Dart invokes a method either
plugin does not handle.
┌─────────────────────────────────┐
│ Flutter (Dart) │
│ BillDog.shared / PaywallView │
│ MethodChannel │
├─────────────────────────────────┤
│ iOS: BillDogFlutterPlugin │
│ → Delegates to BillDog SDK │
├─────────────────────────────────┤
│ Android: BillDogFlutterPlugin │
│ → Delegates to BillDog SDK │
└─────────────────────────────────┘
Hosted paywalls are rendered by the native SDK (full-screen through
registerPlacement, inline through PaywallView). Journey actions are fetched
and executed by the native SDK; Dart does not poll for them.
Requirements
- Flutter >= 3.10.0
- Dart >= 3.0.0
- iOS >= 15.0 — set as the app's deployment target; see Installation above
- Android API >= 21
License
MIT
Libraries
- billdog_flutter
- BillDog Flutter SDK