Your app

Flutter

Keep the official in_app_purchase plugin. Send each purchase to Vend16, complete it once we’ve answered, and unlock from our answer.

1Get your public key

Create a free account, verify your email, and the setup steps ask for your app’s name, iOS bundle ID and Android package name. You get a public key (pk_…, it goes in the app) and a secret key (sk_…, only for a server, if you have one). Both are shown once, so copy them.

2Add it to your app

Use the official in_app_purchase plugin (version 3.2.3 or later). On iPhone it uses StoreKit 2 by default, so purchases come with Apple’s signed JWS. Don’t call enableStoreKit1(): Vend16 needs StoreKit 2, which means iOS 15 or later.

Terminal
flutter pub add in_app_purchase in_app_purchase_android http

Report, then complete:

vend16.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';

const key = 'pk_your_public_key'; // public key, safe in the app
const base = 'https://vend16.com';
const types = {'coins_100': 'consumable', 'pro_monthly': 'subscription'};

enum Outcome { finish, finishUnlockNothing, tryLater }

Future<Outcome> report(PurchaseDetails p, String userId, bool isIos) async {
  try {
    final res = await http.post(Uri.parse('$base/v1/receipts'),
        headers: {'Authorization': 'Bearer $key', 'Content-Type': 'application/json'},
        body: jsonEncode({
          'platform': isIos ? 'ios' : 'android',
          // iOS with StoreKit 2: the signed JWS. Android: the Play purchase token.
          'token': p.verificationData.serverVerificationData,
          'productId': p.productID,
          'productType': types[p.productID],
          'appUserId': userId,
        }));
    if (res.statusCode == 422) return Outcome.finishUnlockNothing;
    if (res.statusCode != 200) return Outcome.tryLater;
    final status = jsonDecode(res.body)['status'];
    return ['recorded', 'duplicate', 'revoked'].contains(status) ? Outcome.finish : Outcome.tryLater;
  } catch (_) {
    return Outcome.tryLater;
  }
}

// Listen once, at launch. On iPhone, unfinished purchases come back here too.
void listen(String userId, bool isIos) {
  InAppPurchase.instance.purchaseStream.listen((purchases) async {
    for (final p in purchases) {
      if (p.status == PurchaseStatus.error || p.status == PurchaseStatus.canceled) {
        if (p.pendingCompletePurchase) await InAppPurchase.instance.completePurchase(p);
        continue;
      }
      if (p.status != PurchaseStatus.purchased && p.status != PurchaseStatus.restored) continue;
      // Already reported and acknowledged (a restore lists everything you own): skip.
      if (p is GooglePlayPurchaseDetails && p.billingClientPurchase.isAcknowledged
          && types[p.productID] != 'consumable') continue;
      final outcome = await report(p, userId, isIos);
      if (outcome == Outcome.tryLater) continue;
      if (!isIos && types[p.productID] == 'consumable' && outcome == Outcome.finish) {
        // Buy consumables with autoConsume: false, and consume only now.
        await InAppPurchase.instance
            .getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
            .consumePurchase(p);
      }
      if (p.pendingCompletePurchase) await InAppPurchase.instance.completePurchase(p);
    }
  });
  // Android doesn't replay purchases left unreported last time; this lists them.
  if (!isIos) InAppPurchase.instance.restorePurchases();
}

Future<List<String>> activeProducts(String userId) async {
  final res = await http.get(Uri.parse('$base/v1/subscribers/${Uri.encodeComponent(userId)}'),
      headers: {'Authorization': 'Bearer $key'});
  return List<String>.from(jsonDecode(res.body)['active_product_ids']);
}

Call listen once at launch, before anything else touches the store. On Android, buy consumables with buyConsumable(purchaseParam: ..., autoConsume: false). With autoConsume on, the plugin consumes the purchase before Vend16 has seen it.

3Keep the money safe

Three rules that protect your money
  1. Tell the store a purchase is finished only after Vend16 answers recorded, duplicate or revoked. If the answer is pending, or Vend16 can’t be reached (a 502 or no network), leave it unfinished. The store hands it back on the next launch and you try again.
  2. If Vend16 answers 422 invalid_receipt, finish it and unlock nothing. The store says it isn’t a real purchase.
  3. Unlock from active_product_ids in GET /v1/subscribers/:appUserId, never from a flag on the phone. Check it at launch and after every purchase.

Use a stable user ID from your own sign-in (or a UUID you keep on the device), never an email address.

4Connect the stores

Your code is done. The stores need to know about your products and about Vend16:

Stuck? Email hello@spruik.co and a real person replies.