Translated from the original Japanese article
Tech 4 min read
Flutter WebView + Google Play Billing Implementation Guide
A guide to implementing Google Play billing (consumable items/point purchases) from a web app inside a WebView.
In short: how do you handle purchases for gacha “stones”? I haven’t implemented this in a production app yet, so these are working notes.
Overall Architecture
[WebView (HTML/JS)] ←→ [Flutter (Dart)] ←→ [Google Play Billing]
JavaScript Channel in_app_purchase
↓
[バックエンドサーバー]
レシート検証・ポイント付与
Purchase Flow for Consumable Items (Points)
1. [ユーザー] ポイント購入ボタン押す
2. [WebView → Flutter] JavaScript Channel で購入リクエスト
3. [Flutter] Google Play 決済画面を起動
4. [Google Play] ユーザーが支払い完了
5. [Flutter] 購入成功を検知
6. [Flutter → サーバー] レシート検証リクエスト
7. [サーバー] Google API でレシート検証 → ポイント付与
8. [Flutter] completePurchase() で消費処理 ← 重要!
9. [Flutter → WebView] 購入完了を通知
Prerequisites
1. Set Up in Google Play Console
Register in-app items
収益化 → アプリ内アイテム → アイテムを作成
アイテムID: point_100 / 100ポイント / ¥120
アイテムID: point_500 / 500ポイント / ¥550
アイテムID: point_1000 / 1000ポイント / ¥1000
※「管理対象のアイテム」を選択(消費型)
※ステータスを「有効」にする
Add license testers (for testing)
設定 → ライセンステスト → テスターのGmailアドレスを追加
2. Flutter Project Setup
pubspec.yaml
dependencies:
webview_flutter: ^4.8.0
in_app_purchase: ^3.2.0
http: ^1.1.0
AndroidManifest.xml
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.INTERNET" />
Flutter-side Implementation
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:http/http.dart' as http;
class WebViewBillingPage extends StatefulWidget {
@override
_WebViewBillingPageState createState() => _WebViewBillingPageState();
}
class _WebViewBillingPageState extends State<WebViewBillingPage> {
late WebViewController _webViewController;
final InAppPurchase _iap = InAppPurchase.instance;
late StreamSubscription<List<PurchaseDetails>> _subscription;
static const Set<String> _productIds = {'point_100', 'point_500', 'point_1000'};
@override
void initState() {
super.initState();
_initBilling();
_initWebView();
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
void _initBilling() {
_subscription = _iap.purchaseStream.listen(
_handlePurchaseUpdates,
onError: (error) => _notifyWebView('purchaseError', {'error': error.toString()}),
);
}
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
for (var purchase in purchases) {
switch (purchase.status) {
case PurchaseStatus.purchased:
await _handleSuccessfulPurchase(purchase);
break;
case PurchaseStatus.error:
_notifyWebView('purchaseError', {'error': purchase.error?.message ?? 'エラー'});
if (purchase.pendingCompletePurchase) await _iap.completePurchase(purchase);
break;
case PurchaseStatus.canceled:
_notifyWebView('purchaseCanceled', {});
break;
case PurchaseStatus.pending:
_notifyWebView('purchasePending', {'message': '支払いを完了してください'});
break;
default:
break;
}
}
}
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
// 1. サーバーでレシート検証
final verified = await _verifyPurchaseOnServer(purchase);
if (verified) {
// 2. 消費処理(再購入可能にする)
if (purchase.pendingCompletePurchase) await _iap.completePurchase(purchase);
// 3. WebViewに成功通知
_notifyWebView('purchaseSuccess', {'productId': purchase.productID});
} else {
_notifyWebView('purchaseError', {'error': 'レシート検証失敗'});
}
}
Future<bool> _verifyPurchaseOnServer(PurchaseDetails purchase) async {
try {
final response = await http.post(
Uri.parse('https://your-server.com/api/verify-purchase'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'productId': purchase.productID,
'purchaseToken': purchase.verificationData.serverVerificationData,
}),
);
return response.statusCode == 200 && jsonDecode(response.body)['success'] == true;
} catch (e) {
return false;
}
}
void _initWebView() {
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('NativeBilling', onMessageReceived: _onJavaScriptMessage)
..loadRequest(Uri.parse('https://your-web-app.com'));
}
Future<void> _onJavaScriptMessage(JavaScriptMessage message) async {
final data = jsonDecode(message.message);
switch (data['action']) {
case 'getProducts':
await _getProducts();
break;
case 'purchase':
await _purchaseProduct(data['productId']);
break;
}
}
Future<void> _getProducts() async {
if (!await _iap.isAvailable()) {
_notifyWebView('productsError', {'error': 'ストアが利用できません'});
return;
}
final response = await _iap.queryProductDetails(_productIds);
final products = response.productDetails.map((p) => {
'id': p.id, 'title': p.title, 'price': p.price,
}).toList();
_notifyWebView('productsLoaded', {'products': products});
}
Future<void> _purchaseProduct(String productId) async {
final response = await _iap.queryProductDetails({productId});
if (response.productDetails.isEmpty) {
_notifyWebView('purchaseError', {'error': '商品が見つかりません'});
return;
}
// autoConsume: false でサーバー検証後に手動消費する(デフォルトは true で自動消費)
await _iap.buyConsumable(
purchaseParam: PurchaseParam(productDetails: response.productDetails.first),
autoConsume: false,
);
}
void _notifyWebView(String event, Map<String, dynamic> data) {
_webViewController.runJavaScript("window.onNativeMessage && window.onNativeMessage('$event', ${jsonEncode(data)});");
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('ポイント購入')),
body: WebViewWidget(controller: _webViewController),
);
}
}
Web Side (JavaScript) Implementation
// Flutterへ送信
function sendToFlutter(action, data = {}) {
if (window.NativeBilling) {
NativeBilling.postMessage(JSON.stringify({ action, ...data }));
}
}
// 商品一覧取得
function loadProducts() { sendToFlutter('getProducts'); }
// 購入
function purchase(productId) { sendToFlutter('purchase', { productId }); }
// Flutterからのコールバック
window.onNativeMessage = function(event, data) {
switch (event) {
case 'productsLoaded':
displayProducts(data.products);
break;
case 'purchaseSuccess':
alert('購入完了!');
refreshUserPoints();
break;
case 'purchaseError':
alert('エラー: ' + data.error);
break;
case 'purchaseCanceled':
console.log('キャンセル');
break;
}
};