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:
- Paywall schema defines abstract product slots (e.g.,
"primary","secondary") - Backend config maps slots to
AWProductrecords, each with astoreProductId(e.g.,com.myapp.pro.yearly) - On config fetch, the SDK pre-fetches StoreKit products via
Product.products(for:)and caches them - At render time,
ProductResolvercombines slot data, dashboard config, and live StoreKit pricing intoResolvedProductInfoobjects - 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":
- SDK resolves the slot name (e.g.,
"primary") to astoreProductId - Looks up the cached
StoreKit.Product(or fetches if not cached) - Calls
Product.purchase()via StoreKit 2 - Verifies the transaction using
VerificationResult - Updates entitlements via
EntitlementManager - Tracks
purchase_startedandtransaction_completeevents - Calls the delegate's
didCompletePurchasemethod
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
| Case | Description |
|---|---|
.purchased | Purchase completed successfully |
.cancelled | User cancelled the purchase |
.pending | Purchase is pending (e.g., Ask to Buy) |
.failed(Error) | Purchase failed with an error |
RestorationResult
| Case | Description |
|---|---|
.restored | Previous purchases were restored |
.noProductsToRestore | No 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:
EntitlementManagerlooks up the product's entitlements bystoreProductId- Adds them to the active entitlements set
- Updates
subscriptionStatusto.active PlacementEvaluatorskips 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
| Status | Description |
|---|---|
.unknown | Not yet determined (before first config fetch) |
.active | User has at least one active subscription |
.inactive | No active subscriptions found |
.expired | Subscription 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
| Expression | Example Output | Description |
|---|---|---|
{{ products.selected.price }} | $49.99 | Localized display price of the selected product |
{{ products.selected.period }} | year | Subscription period unit |
{{ products.selected.period_label }} | /yr | Short period label |
{{ products.selected.price_per_month }} | $4.17 | Normalized monthly price |
{{ products.selected.trial_period }} | 7 days | Free trial duration |
{{ products.selected.trial_price }} | Free | Trial price (or intro price) |
{{ products.selected.has_trial }} | true | Whether the product has a trial |
{{ products.primary.price }} | $49.99 | Price of a specific slot |
{{ products.primary.savings_percentage }} | 50 | Savings 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 Mode | trial_period | trial_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:
- Decodes the JWS payload (with optional signature verification via Apple's x5c certificate chain)
- Extracts transaction details (product ID, purchase date, expiration)
- Looks up entitlements for the product in the project config
- Upserts the transaction and subscription records
- 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
| Field | Type | Description |
|---|---|---|
name | string | Display name |
store | string | "apple", "google", or "stripe" |
storeProductId | string | App Store product identifier |
entitlements | string[] | Entitlement keys granted by this product |
basePlanId | string? | Google Play base plan ID |
offerIds | string[]? | Promotional offer IDs |
displayPrice | string? | Fallback price string |
displayPeriod | string? | 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
StoreKitManagerchecks the cache before initiating a purchase
Troubleshooting
"Product not found" error
- Verify that
storeProductIdin 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.prefetchcompleted successfully (enable.debuglog 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
entitlementsarray configured in the dashboard - Check that
EntitlementManager.updateProductMappingwas called (happens automatically on config fetch) - After a custom
PurchaseControllerpurchase, the SDK still handles entitlement mapping if you return.purchased
Paywall not dismissing after purchase
- Ensure your
PurchaseController.purchase()returns.purchasedon success - The delegate's
didCompletePurchasecallback 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=falseon your dev server to skip JWS signature verification in sandbox