Skip to main content

Payment Implementation

Learn how to implement subscriptions and one-time purchases in your a0 app using the a0-purchases library.
The a0 payment system uses the a0-purchases library. All apps built with a0 come with the necessary configuration and providers built in.

Platform support and testing (important)

  • Web: Stripe (test and production). You can test purchases in the browser.
  • iOS: Apple App Store. You sync products to App Store Connect and test purchases via TestFlight.
  • Android: not supported yet.
Apple payments do not work in the a0 app or Expo Go. To test iOS purchases, you must use TestFlight or a native iOS build.

Prerequisites

Before implementing payments in your code, ensure you have:
1

Completed Payment Setup

Defined your features and plans (either in the dashboard UI or by editing .a0/monetization.yaml via the Monetization tab), and created at least one offering. See the Payment Setup guide.
2

Synced (or linked) to providers

Synced your plans to Stripe and/or Apple App Store Connect.If you’re migrating an existing iOS subscription, link your existing StoreKit product IDs in .a0/monetization.yaml (v2) so your paywall can resolve products immediately (without waiting on a successful first sync).
3

Created Offerings

Created at least one offering and set it as current - this is required for paywalls to display products.
If you’re using the a0 coding agent: it can edit .a0/monetization.yaml directly (same as build.yaml and general.yaml), then sync to Apple/Stripe. It should not tell you to do it yourself.

One-time purchases (lifetime unlocks + consumables)

The same paywall + purchase(packageId) flow supports:
  • non-consumable (buy once + restore): great for “lifetime unlock”
  • consumable (buy many times): great for “credits”, “tokens”, etc
Configure this in .a0/monetization.yaml (v2) with pricingTiers[].productType:
version: 2
bundles:
  - ref: pro
    name: Pro
    # Only required if you're linking existing *subscription* SKUs
    ios:
      subscriptionGroupId: "123456789"
    pricingTiers:
      # Subscription
      - ref: pro_monthly
        type: monthly
        productType: subscription
        displayName: Monthly
        prices: { USD: 9.99 }
        ios:
          productId: com.example.pro.monthly

      # One-time: lifetime unlock (non-consumable)
      - ref: pro_lifetime
        type: lifetime
        productType: non_consumable
        displayName: Lifetime
        prices: { USD: 49.99 }
        ios:
          productId: com.example.pro.lifetime

      # One-time: credits pack (consumable)
      - ref: credits_100
        type: lifetime
        productType: consumable
        displayName: 100 Credits
        prices: { USD: 4.99 }
        ios:
          productId: com.example.credits.100
Notes:
  • type: lifetime + no productType defaults to non_consumable (backwards compatible).
  • Consumables are not restorable on iOS. If you need purchase history, use customerInfo.nonSubscriptionTransactions.
  • For consumables (credits/tokens), treat the purchase as a transaction and fulfill/grant the balance server-side. Do not use local storage as your source of truth.

Understanding Offerings Structure

Before building your paywall, it’s important to understand how offerings are structured:
{
  all: {
    "offering-id-1": {
      identifier: "offering-id-1",
      availablePackages: [...],
      monthly: {...},
      annual: {...}
    },
    "offering-id-2": {
      identifier: "offering-id-2",
      availablePackages: [...]
    }
  },
  current: {
    // The offering marked as "current" in your dashboard
    identifier: "offering-id-1",
    availablePackages: [...]
  }
}
  • offerings.all is an object with offering IDs as keys (not an array) - offerings.current contains the offering marked as current in your a0 dashboard - If no offering is set as current, offerings.current will be null - Use offerings.current when possible, or access specific offerings via their ID from offerings.all

packageId vs productId (don’t mix these up)

  • packageId = pkg.identifier (what you pass to purchase(packageId))
  • productId = pkg.product.identifier (the store purchase ID)
    • iOS: StoreKit product identifier (SKU)

    • Stripe: Stripe price ID
If you’re using monetization.yaml v2, each pricing tier has a stable ref (pricingTiers[].ref). The purchases API includes this as pkg.a0_packageRef, and purchase() accepts it as an alias (LLM-friendly).

Building a Paywall

Here’s a simple paywall component using the useA0Purchases hook:
import { useA0Purchases } from "a0-purchases";

function PaywallScreen() {
  const { isPremium, isLoading, offerings, purchase, restore } = useA0Purchases();

  // Get packages from current offering (recommended) or first available offering
  const currentOffering = offerings?.current || Object.values(offerings?.all || {})[0];
  const packages = currentOffering?.availablePackages || [];

  const handlePurchase = async (pkg) => {
    const packageId = pkg.identifier; // what purchase() expects
    const productId = pkg.product.identifier; // Stripe price ID or iOS SKU
    try {
      await purchase(packageId);
      console.log("Purchased", { packageId, productId });
      if (isPremium) {
        // Success! Navigate to premium content
      }
    } catch (error) {
      if (!error.userCancelled) {
        // Show error to user
      }
    }
  };

  if (isLoading) {
    return <ActivityIndicator />;
  }

  if (packages.length === 0) {
    return <Text>No products available</Text>;
  }

  return (
    <View>
      {packages.map((pkg) => (
        <TouchableOpacity key={pkg.identifier} onPress={() => handlePurchase(pkg)}>
          <Text>{pkg.product.title}</Text>
          <Text>{pkg.product.priceString}</Text>
          <Text>
            {pkg.packageType === "WEEKLY"
              ? "per week"
              : pkg.packageType === "MONTHLY"
                ? "per month"
                : pkg.packageType === "ANNUAL"
                  ? "per year"
                  : ""}
          </Text>
        </TouchableOpacity>
      ))}

      <Button title="Restore Purchases" onPress={restore} />
    </View>
  );
}
Your paywall will show no products if: - You haven’t created any offerings in the a0 dashboard - Your offering has no packages assigned to it - You’re accessing offerings.current but haven’t set an offering as current (use the fallback pattern above) - Your packages are not mapped to provider products (for iOS: StoreKit product IDs / SKUs; for Stripe: price IDs) - You’re testing iOS purchases in the a0 app / Expo Go (use TestFlight / native builds)

Checking Premium Status

The simplest way to gate content is using the isPremium property:
function PremiumContent() {
  const { isPremium } = useA0Purchases();

  if (!isPremium) {
    return <PaywallScreen />;
  }

  return <YourPremiumContent />;
}

Checking Specific Entitlements

isPremium returns true if the user has ANY active entitlement. If you have multiple features/entitlements and need to check specific ones, use getCustomerInfo().
function FeatureGatedContent() {
  const { getCustomerInfo } = useA0Purchases();
  const customerInfo = getCustomerInfo();

  // Check for specific entitlements
  const hasProFeature = customerInfo?.entitlements.active["PRO"];
  const hasAdFree = customerInfo?.entitlements.active["AD_FREE"];
  const hasUnlimitedStorage = customerInfo?.entitlements.active["UNLIMITED_STORAGE"];

  return (
    <View>
      {hasProFeature && <ProContent />}
      {hasAdFree ? <ContentWithoutAds /> : <ContentWithAds />}
      {hasUnlimitedStorage && <UnlimitedUploadButton />}
    </View>
  );
}

Advanced Implementation

Custom Paywall with Package Details

function DetailedPaywallScreen() {
  const { offerings, purchase, isPremium } = useA0Purchases();
  const currentOffering = offerings?.current;

  if (!currentOffering) {
    return <Text>Loading products...</Text>;
  }

  // Get different package types
  const weeklyPackage = currentOffering.weekly;
  const monthlyPackage = currentOffering.monthly;
  const annualPackage = currentOffering.annual;

  return (
    <View>
      <Text>Choose your plan:</Text>

      {weeklyPackage && (
        <PlanOption package={weeklyPackage} onPress={() => purchase(weeklyPackage.identifier)} />
      )}

      {monthlyPackage && (
        <PlanOption
          package={monthlyPackage}
          onPress={() => purchase(monthlyPackage.identifier)}
          highlighted={true} // Most popular
        />
      )}

      {annualPackage && (
        <PlanOption
          package={annualPackage}
          onPress={() => purchase(annualPackage.identifier)}
          savings="Save 25%"
        />
      )}
    </View>
  );
}

Handling Purchase States

import { PURCHASES_ERROR_CODE, useA0Purchases } from "a0-purchases";

function PurchaseButton({ packageId }) {
  const { purchase } = useA0Purchases();
  const [isPurchasing, setIsPurchasing] = useState(false);
  const [error, setError] = useState(null);

  const handlePurchase = async () => {
    setIsPurchasing(true);
    setError(null);

    try {
      const result = await purchase(packageId);
      // Success - the hook will update isPremium automatically
      navigation.navigate("Success");
    } catch (err) {
      if (err.userCancelled) {
        // User cancelled - no need to show error
      } else if (err.code === PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
        // User already has this product
        setError("You already have an active subscription");
      } else {
        // Other error
        setError("Purchase failed. Please try again.");
      }
    } finally {
      setIsPurchasing(false);
    }
  };

  return (
    <>
      <Button title="Subscribe Now" onPress={handlePurchase} disabled={isPurchasing} />
      {error && <Text style={{ color: "red" }}>{error}</Text>}
    </>
  );
}

Testing Purchases

Test Credentials

Stripe Test Cards:
  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • More test cards in Stripe docs
Apple Sandbox:
  • Sync products to App Store Connect (production), then test purchases via TestFlight using sandbox tester accounts
  • Subscriptions auto-renew every few minutes for testing
  • Payments do not work in the a0 app or Expo Go (use TestFlight / native builds)

Testing Flow

1

Enable Test Mode

For Stripe, ensure you synced to the sandbox environment in your payment setup.
2

Test Purchase Flow

Make test purchases using the test credentials above.
3

Verify Entitlements

Check that isPremium updates correctly and specific entitlements are active.
4

Test Restore

Clear app data and test the restore purchases functionality.

Troubleshooting

Cause: offerings.all is an object (with offering IDs as keys), not an arraySolution: Use one of these patterns instead:
// Option 1: Use current offering (recommended)
const packages = offerings?.current?.availablePackages || [];

// Option 2: Get first offering from all
const firstOffering = Object.values(offerings?.all || {})[0];
const packages = firstOffering?.availablePackages || [];

// Option 3: Use current with fallback to first available
const currentOffering = offerings?.current || Object.values(offerings?.all || {})[0];
const packages = currentOffering?.availablePackages || [];
Debug your offerings structure by logging:
console.log('Offerings structure:', JSON.stringify(offerings, null, 2));
Common causes:
  • No offerings created or no current offering set
  • The offering has packages, but the packages are not mapped to provider products for the current platform/environment
Solution:
  • Go to a0 dashboard → Payments → Offerings
  • Create an offering with your subscription plans
  • Set it as the current offering
If you already have offerings/packages configured, make sure the packages are mapped to provider products:
  • iOS (existing products): use .a0/monetization.yaml v2 linking
    • bundles[].ios.subscriptionGroupId: numeric App Store Connect subscription group id (digits only)
    • bundles[].pricingTiers[].ios.productId: existing StoreKit product identifier (SKU)
  • Stripe (existing prices): use .a0/monetization.yaml v2 linking
    • bundles[].pricingTiers[].stripe.priceId: Stripe live price id (production)
    • bundles[].pricingTiers[].stripe.testPriceId: Stripe test price id (development)
  • iOS / Stripe (new products): run a provider sync after editing monetization so mappings are created
After a successful provider sync, a0 writes the resulting provider IDs back into .a0/monetization.yaml (v2) to reduce drift.
Common causes:
  • Not synced to payment provider
  • Testing iOS purchases in the a0 app / Expo Go (must use TestFlight / native build)
  • Testing with production credentials
  • Invalid product identifiers
Debug steps:
const { offerings } = useA0Purchases();
console.log("Available offerings:", offerings);
console.log("Current offering:", offerings?.current);
console.log("Available packages:", offerings?.current?.availablePackages);
Cause: Customer info not refreshingSolution: The hook should update automatically, but you can force a refresh:
const { refreshCustomerInfo } = useA0Purchases();

// Force refresh after purchase
await refreshCustomerInfo();
Cause: Features not properly configured in a0 dashboardSolution:
  • Verify feature names match exactly (case-sensitive)
  • Ensure plans include the features you’re checking
  • Sync plans to payment providers after changes

Next Steps