SDK
Purchases & StoreKit

Purchases & StoreKit

AgentWallie handles StoreKit 2 purchases out of the box. No setup is needed for basic purchase flows -- the SDK manages product resolution, payment processing, receipt validation, and entitlement updates automatically.

For apps using RevenueCat, Qonversion, or custom billing, you can provide a custom PurchaseController to override the default behavior.

How Product Resolution Works

The SDK resolves real App Store prices through a multi-step pipeline:

  1. Paywall schema defines abstract product slots (e.g., "primary", "secondary")
  2. Backend config maps slots to AWProduct records, each with a storeProductId (e.g., com.myapp.pro.yearly)
  3. On config fetch, the SDK pre-fetches StoreKit products via Product.products(for:) and caches them
  4. At render time, ProductResolver combines slot data, dashboard config, and live StoreKit pricing into ResolvedProductInfo objects
  5. Expressions like {{ products.selected.price }} resolve to localized prices automatically
Paywall Schema          Dashboard Config         StoreKit
┌──────────────┐       ┌──────────────────┐     ┌─────────────────┐
│ slot: primary │──────▶│ storeProductId:  │────▶│ Product.products│
│ label: Yearly │       │ com.app.yearly   │     │ → $49.99/yr     │
└──────────────┘       │ entitlements:     │     └─────────────────┘
                       │   ["pro"]         │
                       └──────────────────┘

Default StoreKit 2 Integration

StoreKit 2 is used by default. No additional setup is required beyond calling configure:

// StoreKit 2 is used automatically
AgentWallie.configure(apiKey: "pk_...")

When a user taps a CTA button with action "purchase":

  1. SDK resolves the slot name (e.g., "primary") to a storeProductId
  2. Looks up the cached StoreKit.Product (or fetches if not cached)
  3. Calls Product.purchase() via StoreKit 2
  4. Verifies the transaction using VerificationResult
  5. Updates entitlements via EntitlementManager
  6. Tracks purchase_started and transaction_complete events
  7. Calls the delegate's didCompletePurchase method

The SDK also listens for Transaction.updates in the background to handle renewals, revocations, and other transaction changes that occur outside the purchase flow.

Custom PurchaseController

If your app uses RevenueCat or another billing provider, implement the PurchaseController protocol:

protocol PurchaseController: AnyObject, Sendable {
    func purchase(productId: String) async throws -> PurchaseResult
    func restorePurchases() async throws -> RestorationResult
}

PurchaseResult

CaseDescription
.purchasedPurchase completed successfully
.cancelledUser cancelled the purchase
.pendingPurchase is pending (e.g., Ask to Buy)
.failed(Error)Purchase failed with an error

RestorationResult

CaseDescription
.restoredPrevious purchases were restored
.noProductsToRestoreNo restorable purchases found
.failed(Error)Restoration failed with an error

RevenueCat Example

import RevenueCat
 
class RevenueCatController: PurchaseController {
    func purchase(productId: String) async throws -> PurchaseResult {
        let products = try await Purchases.shared.products([productId])
        guard let product = products.first else {
            return .failed(StoreKitError.productNotFound)
        }
 
        let (_, customerInfo, userCancelled) = try await Purchases.shared.purchase(product: product)
 
        if userCancelled {
            return .cancelled
        }
 
        return .purchased
    }
 
    func restorePurchases() async throws -> RestorationResult {
        let customerInfo = try await Purchases.shared.restorePurchases()
        return customerInfo.entitlements.active.isEmpty ? .noProductsToRestore : .restored
    }
}

Set your custom controller before calling configure:

// In your App init or AppDelegate
AgentWallie.shared.purchaseController = RevenueCatController()
AgentWallie.configure(apiKey: "pk_...")

When using a custom PurchaseController, the SDK still handles product resolution, event tracking, and entitlement mapping. Only the actual purchase and restore calls are delegated to your controller.

Entitlements

Products are configured with an entitlements array that maps purchases to feature access:

{
  "name": "Pro Yearly",
  "store": "apple",
  "store_product_id": "com.myapp.pro.yearly",
  "entitlements": ["pro", "premium_content"]
}

After a successful purchase:

  1. EntitlementManager looks up the product's entitlements by storeProductId
  2. Adds them to the active entitlements set
  3. Updates subscriptionStatus to .active
  4. PlacementEvaluator skips paywalls when the user already has the required entitlement
// Entitlements update automatically after purchase.
// You can also check manually:
if AgentWallie.shared.entitlements.contains("pro") {
    showProFeature()
}

The EntitlementManager also refreshes from StoreKit on app launch by iterating Transaction.currentEntitlements, so entitlement state stays accurate across sessions.

Subscription Status

Subscription status is automatically derived from StoreKit's Transaction.currentEntitlements and updated on:

  • App launch (during config fetch)
  • Successful purchase
  • Successful restore
  • Background transaction updates
StatusDescription
.unknownNot yet determined (before first config fetch)
.activeUser has at least one active subscription
.inactiveNo active subscriptions found
.expiredSubscription has expired

Delegate Callback

func didCompletePurchase(productId: String) {
    // subscriptionStatus and entitlements are already updated at this point
    print("Status: \(AgentWallie.shared.subscriptionStatus)")
    print("Entitlements: \(AgentWallie.shared.entitlements)")
}
 
func didRestorePurchases() {
    // Entitlements refreshed from StoreKit
    updateUI()
}

Real Prices in Paywalls

Paywall schemas use expression syntax to display live, localized prices from StoreKit. These are resolved at render time from ResolvedProductInfo:

Available Expressions

ExpressionExample OutputDescription
{{ products.selected.price }}$49.99Localized display price of the selected product
{{ products.selected.period }}yearSubscription period unit
{{ products.selected.period_label }}/yrShort period label
{{ products.selected.price_per_month }}$4.17Normalized monthly price
{{ products.selected.trial_period }}7 daysFree trial duration
{{ products.selected.trial_price }}FreeTrial price (or intro price)
{{ products.selected.has_trial }}trueWhether the product has a trial
{{ products.primary.price }}$49.99Price of a specific slot
{{ products.primary.savings_percentage }}50Savings vs monthly pricing

How Savings Are Calculated

The SDK normalizes all products to a monthly price, then calculates the percentage difference relative to the monthly product:

  • Monthly at $9.99/mo = baseline
  • Yearly at $49.99/yr = $4.17/mo = 58% savings
{
  "type": "product_picker",
  "props": {
    "layout": "horizontal",
    "show_savings_badge": true,
    "savings_text": "Save {{ products.primary.savings_percentage }}%"
  }
}

Trial Info Resolution

Trial information is extracted from StoreKit's introductoryOffer:

Payment Modetrial_periodtrial_price
Free trial"7 days""Free"
Pay as you go"3 months""$0.99"
Pay up front"1 year""$29.99"

Conditional Trial Display

Use conditionals to show trial-specific copy only when a trial is available:

{
  "type": "text",
  "props": { "content": "Start your {{ products.selected.trial_period }} free trial" },
  "condition": { "field": "products.selected.has_trial", "operator": "is", "value": true }
}

Server-Side Receipt Validation

After a successful StoreKit 2 purchase, the SDK posts the signed transaction to the backend for server-side validation. See the Receipt Validation API for full endpoint documentation.

POST /v1/receipts/:publicKey
Content-Type: application/json
 
{
  "signed_transaction_info": "eyJ...",
  "user_id": "user_123",
  "device_id": "device_abc"
}

The backend:

  1. Decodes the JWS payload (with optional signature verification via Apple's x5c certificate chain)
  2. Extracts transaction details (product ID, purchase date, expiration)
  3. Looks up entitlements for the product in the project config
  4. Upserts the transaction and subscription records
  5. Returns validation result with entitlements
{
  "valid": true,
  "transaction_id": "1000000123456789",
  "original_transaction_id": "1000000123456000",
  "product_id": "com.myapp.pro.yearly",
  "entitlements": ["pro"],
  "expires_date": "2027-03-20T00:00:00.000Z",
  "status": "active"
}

Subscription Status API

Query subscription status for any user:

GET /v1/subscriptions/:publicKey/:userId
{
  "user_id": "user_123",
  "status": "active",
  "entitlements": ["pro"],
  "subscriptions": [
    {
      "product_id": "com.myapp.pro.yearly",
      "status": "active",
      "current_period_end": "2027-03-20T00:00:00.000Z",
      "auto_renew_enabled": true
    }
  ]
}

Setting Up Products

1. Create Products in App Store Connect

Set up your subscription products or in-app purchases in App Store Connect (opens in a new tab).

2. Add Products to AgentWallie

Use the API to register your products:

POST /v1/projects/:id/products
Authorization: Bearer sk_your_private_key
Content-Type: application/json
 
{
  "name": "Pro Yearly",
  "store": "apple",
  "storeProductId": "com.myapp.pro.yearly",
  "entitlements": ["pro"],
  "displayPrice": "$49.99",
  "displayPeriod": "year"
}

Or use the MCP tool:

agentwallie_add_product({
  project_id: "proj_xxx",
  name: "Pro Yearly",
  store: "apple",
  store_product_id: "com.myapp.pro.yearly",
  entitlements: ["pro"],
  display_price: "$49.99",
  display_period: "year"
})

displayPrice and displayPeriod are fallback values shown when StoreKit product data is unavailable (e.g., in the dashboard preview). The SDK always prefers live StoreKit prices at runtime.

3. Reference in Paywall Schema

Define product slots in your paywall schema. Slots are abstract names that get bound to real products:

{
  "products": [
    { "slot": "primary", "label": "Yearly" },
    { "slot": "secondary", "label": "Monthly" }
  ]
}

4. Bind Products to Slots

Products are bound to slots at the campaign/audience level via the dashboard or API. The SDK config includes the mapping, and ProductResolver handles the rest.

AWProduct Fields

FieldTypeDescription
namestringDisplay name
storestring"apple", "google", or "stripe"
storeProductIdstringApp Store product identifier
entitlementsstring[]Entitlement keys granted by this product
basePlanIdstring?Google Play base plan ID
offerIdsstring[]?Promotional offer IDs
displayPricestring?Fallback price string
displayPeriodstring?Fallback period string

Product Caching

The SDK uses StoreKitProductCache (an actor) to avoid redundant Product.products(for:) calls:

  • Products are prefetched on config fetch
  • Cache is reused across paywall presentations
  • Cache is skipped if product IDs change between config refreshes
  • StoreKitManager checks the cache before initiating a purchase

Troubleshooting

"Product not found" error

  • Verify that storeProductId in your AgentWallie product config exactly matches the product ID in App Store Connect
  • Ensure the product is approved and available in your StoreKit configuration or sandbox environment
  • Check that StoreKitProductCache.prefetch completed successfully (enable .debug log level)

Prices showing fallback values instead of real prices

  • Check that StoreKit product fetch succeeded (look for "Prefetched N StoreKit products" in logs)
  • Verify your StoreKit configuration file is set up correctly for testing
  • Ensure the product IDs are valid Apple product identifiers

Entitlements not updating after purchase

  • Verify the product has a non-empty entitlements array configured in the dashboard
  • Check that EntitlementManager.updateProductMapping was called (happens automatically on config fetch)
  • After a custom PurchaseController purchase, the SDK still handles entitlement mapping if you return .purchased

Paywall not dismissing after purchase

  • Ensure your PurchaseController.purchase() returns .purchased on success
  • The delegate's didCompletePurchase callback is where you should update UI state

Testing in Sandbox

  • Use Xcode's StoreKit Configuration file for local testing
  • StoreKit 2 sandbox transactions work with the same code path as production
  • Set APPSTORE_VERIFY_RECEIPTS=false on your dev server to skip JWS signature verification in sandbox