Skip to main content

Payment Implementation

Learn how to implement subscription payments 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.

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 to Providers

Synced your plans to Stripe and/or Apple App Store Connect.
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.

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

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 (packageId) => {
    try {
      await purchase(packageId);
      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.identifier)}>
          <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)

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

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 === "PRODUCT_ALREADY_PURCHASED") {
        // 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:
  • Use App Store Connect sandbox tester accounts
  • Subscriptions auto-renew every few minutes for testing
  • Clear purchase history in Settings → App Store → Sandbox Account

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));
Cause: No offerings created or no current offering setSolution:
  • Go to a0 dashboard → Payments → Offerings
  • Create an offering with your subscription plans
  • Set it as the current offering
Common causes:
  • Not synced to payment provider
  • 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 { getCustomerInfo } = useA0Purchases();

// Force refresh after purchase
await getCustomerInfo(true); // true forces a network fetch
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