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:
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:
  • 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:
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:

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().

Advanced Implementation

Custom Paywall with Package Details

Handling Purchase States

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:
Debug your offerings structure by logging:
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:
Cause: Customer info not refreshingSolution: The hook should update automatically, but you can force a refresh:
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

a0-purchases API Reference

Complete library documentation with all available methods and properties.

Test on Real Devices

Build and test payment flows on physical devices.