billing_country

Cross-platform Flutter plugin that reports the billing storefront country for the current device and helps you gate third-party / external payment flows for digital goods.

  • iOS → App Store storefront country via StoreKit (Storefront.current / SKPaymentQueue.storefront).
  • Android → Google Play billing country via BillingClient.getBillingConfigAsync(), falling back to the SIM / device locale country when Play billing is unavailable.

All country codes are normalized to ISO 3166-1 alpha-2 (e.g. US, IN, RU) regardless of platform. (iOS reports alpha-3 internally; the plugin converts it.)

This plugin only tells you where the user is billed. It does not decide whether third-party payments are actually allowed — Apple and Google decide that per program, app category, and date. The built-in country lists are illustrative defaults, not legal advice. Configure them yourself.

Why

Apple and Google both restrict selling digital goods through third-party payment systems, but both allow it in certain regions/programs (e.g. the US external purchase link entitlement on iOS; India, South Korea, and Russia among others on Google Play). Deciding which payment UI to show usually hinges on the user's billing region, not their device locale. This plugin gives you that region on both platforms behind one API.

Install

dependencies:
  billing_country: ^1.0.0
  • iOS: minimum iOS 13 (StoreKit Storefront.current is used on iOS 15+, with an SKPaymentQueue fallback below that).
  • Android: bundles Google Play Billing Library (7.1.1 by default; adjust in android/build.gradle.kts if your app needs another version). minSdk 24.

Usage

import 'package:billing_country/billing_country.dart';

// Which store are we on?
final BillingStore store = BillingCountry.store; // appStore / playStore / unknown

// ISO 3166-1 alpha-2 billing country, or null if unavailable. Never throws.
final String? country = await BillingCountry.getCountryCode(); // e.g. "IN"

// Convenience.
final bool isUS = await BillingCountry.isUnitedStates();

// Gate third-party payment for the *current* device in one call.
if (await BillingCountry.isExternalPaymentSupportedHere()) {
  // show your own / external payment flow
} else {
  // fall back to StoreKit / Google Play Billing
}

Configuring supported countries

The defaults are only a starting point. Set your own lists per store — driven by your legal/compliance decision, ideally from a remote config so you can change them without shipping an update:

BillingCountry.setSupportedCountries(
  appStore: {'US'},
  playStore: {'IN', 'RU', 'KR', 'BR'},
);

// Check an arbitrary code against the current store's list.
final bool ok = BillingCountry.isExternalPaymentSupported('IN');

// Inspect / reset.
final Set<String> current = BillingCountry.supportedCountriesForCurrentStore();
BillingCountry.resetSupportedCountries();

Defaults:

Store Default supported countries
App Store US
Play Store IN, RU, KR

Driving the lists from remote config

The package is transport-agnostic and adds no networking / Firebase dependency. Feed it a JSON payload of this shape from any source:

{ "appStore": ["US"], "playStore": ["IN", "RU", "KR"] }

Apply a map you already have:

BillingCountry.updateFromJson(remoteMap);

Or hand it a fetcher that returns the raw JSON string — updateFromRemote decodes, applies, and never throws (returns false and leaves the config untouched on any failure):

// Firebase Remote Config
import 'package:firebase_remote_config/firebase_remote_config.dart';

final rc = FirebaseRemoteConfig.instance;
await rc.fetchAndActivate();
await BillingCountry.updateFromRemote(
  () async => rc.getString('billing_country_support'),
);
// Plain HTTP endpoint
import 'package:http/http.dart' as http;

await BillingCountry.updateFromRemote(() async {
  final res = await http.get(Uri.parse('https://example.com/billing.json'));
  return res.statusCode == 200 ? res.body : null;
});

Rules:

  • Only keys present in the payload are applied; an omitted store keeps its current list.
  • Codes are uppercased; non-string / blank entries are dropped.
  • An explicit empty array ("playStore": []) clears that store.

Call updateFromRemote early (e.g. on app start, before your first payment gate) and re-run it whenever remote config refreshes.

API

Member Description
BillingCountry.store BillingStore.appStore / playStore / unknown for the current platform.
getCountryCode() Future<String?> — ISO alpha-2 billing country, or null. Never throws.
isUnitedStates() Future<bool>true when the billing country is US.
isExternalPaymentSupported(code) bool — is code (alpha-2) in the current store's configured list.
isExternalPaymentSupportedHere() Future<bool> — resolves the current country and checks it.
setSupportedCountries({appStore, playStore}) Override the per-store lists.
updateFromJson(map) Apply a {appStore:[], playStore:[]} config map. Returns bool.
updateFromRemote(fetch) Future<bool> — fetch a raw JSON string and apply it. Never throws.
supportedCountriesForCurrentStore() The configured list for the current store.
appStoreSupportedCountries / playStoreSupportedCountries The configured list per store (any platform).
resetSupportedCountries() Restore library defaults.
normalizeToAlpha2(raw) Utility: convert an alpha-3 or alpha-2 code to alpha-2 (exported).

Notes & caveats

  • Per Google's docs, the value from getBillingConfigAsync() is meant for one-time use and may change; do not persist it or use it for profiling / tracking / advertising.
  • On Android, if the Play Store is missing or the billing connection fails, the plugin falls back to SIM country, then network country, then device locale — which is an approximation of the billing region, not the billing region itself. If you need Play-only accuracy, treat a null/fallback result accordingly in your own logic.
  • On unsupported platforms (desktop, web) store is unknown and getCountryCode() returns null.