> ## Documentation Index
> Fetch the complete documentation index at: https://docs.a0.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Payment Implementation

> Integrate subscriptions and one-time purchases into your a0 app with code examples

# Payment Implementation

Learn how to implement subscriptions and one-time purchases in your a0 app using the `a0-purchases` library.

<Info>
  The a0 payment system uses the [`a0-purchases`](https://www.npmjs.com/package/a0-purchases)
  library. All apps built with a0 come with the necessary configuration and providers built in.
</Info>

## Platform support and testing (important)

<Info>
  * <strong>Web</strong>: Stripe (test and production). You can test purchases in the browser.
  * <strong>iOS</strong>: Apple App Store. You sync products to App Store Connect and test purchases via TestFlight.
  * <strong>Android</strong>: not supported yet.
</Info>

<Warning>
  Apple payments do <strong>not</strong> work in the a0 app or Expo Go. To test iOS purchases, you must use
  TestFlight or a native iOS build.
</Warning>

## Prerequisites

Before implementing payments in your code, ensure you have:

<Steps>
  <Step title="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](/development/monetization/payment-setup) guide.
  </Step>

  <Step title="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).
  </Step>

  <Step title="Created Offerings">
    Created at least one offering and set it as current - this is required for paywalls to display products.
  </Step>
</Steps>

<Info>
  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.
</Info>

## 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`:

```yaml theme={null}
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:

```javascript theme={null}
{
  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: [...]
  }
}
```

<Note>
  * `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`
</Note>

## packageId vs productId (don’t mix these up)

<Info>
  * <strong>packageId</strong> = <code>pkg.identifier</code> (what you pass to <code>purchase(packageId)</code>)
  * <strong>productId</strong> = <code>pkg.product.identifier</code> (the store purchase ID)
      <br />
    * iOS: StoreKit product identifier (SKU)
      <br />
    * Stripe: Stripe price ID
</Info>

<Tip>
  If you're using <code>monetization.yaml</code> v2, each pricing tier has a stable <code>ref</code>
  (<code>pricingTiers\[].ref</code>). The purchases API includes this as <code>pkg.a0\_packageRef</code>,
  and <code>purchase()</code> accepts it as an alias (LLM-friendly).
</Tip>

## Building a Paywall

Here's a simple paywall component using the `useA0Purchases` hook:

```javascript theme={null}
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>
  );
}
```

<Warning>
  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)
</Warning>

## Checking Premium Status

The simplest way to gate content is using the `isPremium` property:

```javascript theme={null}
function PremiumContent() {
  const { isPremium } = useA0Purchases();

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

  return <YourPremiumContent />;
}
```

## Checking Specific Entitlements

<Info>
  `isPremium` returns true if the user has ANY active entitlement. If you have multiple
  features/entitlements and need to check specific ones, use `getCustomerInfo()`.
</Info>

```javascript theme={null}
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

```javascript theme={null}
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

```javascript theme={null}
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](https://stripe.com/docs/testing)

**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

<Steps>
  <Step title="Enable Test Mode">
    For Stripe, ensure you synced to the sandbox environment in your payment setup.
  </Step>

  <Step title="Test Purchase Flow">Make test purchases using the test credentials above.</Step>

  <Step title="Verify Entitlements">
    Check that `isPremium` updates correctly and specific entitlements are active.
  </Step>

  <Step title="Test Restore">
    Clear app data and test the restore purchases functionality.
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="offerings.all[0] returns undefined">
    **Cause**: `offerings.all` is an object (with offering IDs as keys), not an array

    **Solution**:
    Use one of these patterns instead:

    ```javascript theme={null}
    // 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 || [];
    ```

    <Tip>
      Debug your offerings structure by logging:

      ```javascript theme={null}
      console.log('Offerings structure:', JSON.stringify(offerings, null, 2));
      ```
    </Tip>
  </Accordion>

  <Accordion title="Paywall shows 'No products available'">
    **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

    <Tip>
      After a successful provider sync, a0 writes the resulting provider IDs back into `.a0/monetization.yaml` (v2) to reduce drift.
    </Tip>
  </Accordion>

  <Accordion title="Purchase fails immediately">
    **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**:

    ```javascript theme={null}
    const { offerings } = useA0Purchases();
    console.log("Available offerings:", offerings);
    console.log("Current offering:", offerings?.current);
    console.log("Available packages:", offerings?.current?.availablePackages);
    ```
  </Accordion>

  <Accordion title="isPremium not updating after purchase">
    **Cause**: Customer info not refreshing

    **Solution**:
    The hook should update automatically, but you can force a refresh:

    ```javascript theme={null}
    const { refreshCustomerInfo } = useA0Purchases();

    // Force refresh after purchase
    await refreshCustomerInfo();
    ```
  </Accordion>

  <Accordion title="Entitlements showing as undefined">
    **Cause**: Features not properly configured in a0 dashboard

    **Solution**:

    * Verify feature names match exactly (case-sensitive)
    * Ensure plans include the features you're checking
    * Sync plans to payment providers after changes
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="a0-purchases API Reference" icon="book" href="https://www.npmjs.com/package/a0-purchases">
    Complete library documentation with all available methods and properties.
  </Card>

  <Card title="Test on Real Devices" icon="mobile" href="/development/testing/native-build-testing">
    Build and test payment flows on physical devices.
  </Card>
</CardGroup>
