This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
1561 lines
36 KiB
Markdown
1561 lines
36 KiB
Markdown
---
|
|
name: axiom-storekit-ref
|
|
description: Reference — Complete StoreKit 2 API guide covering Product, Transaction, AppTransaction, RenewalInfo, SubscriptionStatus, StoreKit Views, purchase options, server APIs, and all iOS 18.4 enhancements with WWDC 2025 code examples
|
|
license: MIT
|
|
compatibility: iOS 15+ (iOS 18.4+ for latest features)
|
|
metadata:
|
|
version: "1.0.0"
|
|
last-updated: "2025-12-08"
|
|
---
|
|
|
|
# StoreKit 2 — Complete API Reference
|
|
|
|
## Overview
|
|
|
|
StoreKit 2 is Apple's modern in-app purchase framework with async/await APIs, automatic receipt validation, and SwiftUI integration. This reference covers every API, iOS 18.4 enhancements, and comprehensive WWDC 2025 code examples.
|
|
|
|
### Product Types Supported
|
|
|
|
**Consumable**:
|
|
- Products that can be purchased multiple times
|
|
- Examples: coins, hints, temporary boosts
|
|
- Do NOT restore on new devices
|
|
|
|
**Non-Consumable**:
|
|
- Products purchased once, owned forever
|
|
- Examples: premium features, level packs, remove ads
|
|
- MUST restore on new devices
|
|
|
|
**Auto-Renewable Subscription**:
|
|
- Subscriptions that renew automatically
|
|
- Organized into subscription groups
|
|
- MUST restore on new devices
|
|
- Support: free trials, intro offers, promotional offers, win-back offers
|
|
|
|
**Non-Renewing Subscription**:
|
|
- Fixed duration subscriptions (no auto-renewal)
|
|
- Examples: seasonal passes
|
|
- MUST restore on new devices
|
|
|
|
### Key Improvements Over StoreKit 1
|
|
|
|
- **Async/Await**: Modern concurrency instead of delegates/closures
|
|
- **Automatic Verification**: JSON Web Signature (JWS) verification built-in
|
|
- **Transaction Types**: Strong Swift types instead of SKPaymentTransaction
|
|
- **Testing**: StoreKit configuration files for local testing
|
|
- **SwiftUI Views**: Pre-built purchase UIs (ProductView, SubscriptionStoreView)
|
|
- **Server APIs**: App Store Server API and Server Notifications
|
|
|
|
---
|
|
|
|
## When to Use This Reference
|
|
|
|
Use this reference when:
|
|
- Implementing in-app purchases with StoreKit 2
|
|
- Understanding new iOS 18.4 fields (appTransactionID, offerPeriod, etc.)
|
|
- Looking up specific API signatures and parameters
|
|
- Planning subscription architecture
|
|
- Debugging transaction issues
|
|
- Implementing StoreKit Views
|
|
- Integrating with App Store Server APIs
|
|
|
|
**Related Skills**:
|
|
- `axiom-in-app-purchases` — Discipline skill with testing-first workflow, architecture patterns
|
|
- (Future: `iap-auditor` agent for auditing existing IAP code)
|
|
- (Future: `iap-implementation` agent for implementing IAP from scratch)
|
|
|
|
---
|
|
|
|
## Product
|
|
|
|
### Overview
|
|
|
|
`Product` represents an in-app purchase item configured in App Store Connect or StoreKit configuration file.
|
|
|
|
### Loading Products
|
|
|
|
**Basic Loading**:
|
|
```swift
|
|
import StoreKit
|
|
|
|
let productIDs = [
|
|
"com.app.coins_100",
|
|
"com.app.premium",
|
|
"com.app.pro_monthly"
|
|
]
|
|
|
|
let products = try await Product.products(for: productIDs)
|
|
```
|
|
|
|
#### From WWDC 2021-10114
|
|
|
|
**Handling Missing Products**:
|
|
```swift
|
|
let products = try await Product.products(for: productIDs)
|
|
|
|
// Check what loaded
|
|
let loadedIDs = Set(products.map { $0.id })
|
|
let missingIDs = Set(productIDs).subtracting(loadedIDs)
|
|
|
|
if !missingIDs.isEmpty {
|
|
print("Missing products: \(missingIDs)")
|
|
// Products not configured in App Store Connect or .storekit file
|
|
}
|
|
```
|
|
|
|
### Product Properties
|
|
|
|
**Basic Properties**:
|
|
```swift
|
|
let product: Product
|
|
|
|
product.id // "com.app.premium"
|
|
product.displayName // "Premium Upgrade"
|
|
product.description // "Unlock all features"
|
|
product.displayPrice // "$4.99"
|
|
product.price // Decimal(4.99)
|
|
product.type // .nonConsumable
|
|
```
|
|
|
|
**Product Type Enum**:
|
|
```swift
|
|
switch product.type {
|
|
case .consumable:
|
|
// Coins, hints, boosts
|
|
case .nonConsumable:
|
|
// Premium features, level packs
|
|
case .autoRenewable:
|
|
// Monthly/annual subscriptions
|
|
case .nonRenewing:
|
|
// Seasonal passes
|
|
@unknown default:
|
|
break
|
|
}
|
|
```
|
|
|
|
### Subscription-Specific Properties
|
|
|
|
**Check if Product is Subscription**:
|
|
```swift
|
|
if let subscriptionInfo = product.subscription {
|
|
// Product is auto-renewable subscription
|
|
let groupID = subscriptionInfo.subscriptionGroupID
|
|
let period = subscriptionInfo.subscriptionPeriod
|
|
}
|
|
```
|
|
|
|
**Subscription Period**:
|
|
```swift
|
|
let period = product.subscription?.subscriptionPeriod
|
|
|
|
switch period?.unit {
|
|
case .day:
|
|
print("\(period?.value ?? 0) days")
|
|
case .week:
|
|
print("\(period?.value ?? 0) weeks")
|
|
case .month:
|
|
print("\(period?.value ?? 0) months")
|
|
case .year:
|
|
print("\(period?.value ?? 0) years")
|
|
default:
|
|
break
|
|
}
|
|
```
|
|
|
|
**Introductory Offer**:
|
|
```swift
|
|
if let introOffer = product.subscription?.introductoryOffer {
|
|
print("Free trial: \(introOffer.period.value) \(introOffer.period.unit)")
|
|
print("Price: \(introOffer.displayPrice)")
|
|
|
|
switch introOffer.paymentMode {
|
|
case .freeTrial:
|
|
print("Free trial - no charge")
|
|
case .payAsYouGo:
|
|
print("Discounted price per period")
|
|
case .payUpFront:
|
|
print("One-time discounted price")
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
**Promotional Offers**:
|
|
```swift
|
|
let offers = product.subscription?.promotionalOffers ?? []
|
|
|
|
for offer in offers {
|
|
print("Offer ID: \(offer.id)")
|
|
print("Price: \(offer.displayPrice)")
|
|
print("Period: \(offer.period.value) \(offer.period.unit)")
|
|
}
|
|
```
|
|
|
|
### Purchase Methods
|
|
|
|
**Purchase with UI Context (iOS 18.2+)**:
|
|
```swift
|
|
let product: Product
|
|
let scene: UIWindowScene
|
|
|
|
let result = try await product.purchase(confirmIn: scene)
|
|
```
|
|
|
|
#### From WWDC 2025-241:9:32
|
|
|
|
**Purchase with Options**:
|
|
```swift
|
|
let accountToken = UUID()
|
|
|
|
let result = try await product.purchase(
|
|
confirmIn: scene,
|
|
options: [
|
|
.appAccountToken(accountToken)
|
|
]
|
|
)
|
|
```
|
|
|
|
#### From WWDC 2025-241:11:01
|
|
|
|
**Purchase with Promotional Offer (JWS Format)**:
|
|
```swift
|
|
let jwsSignature: String // From your server
|
|
|
|
let result = try await product.purchase(
|
|
confirmIn: scene,
|
|
options: [
|
|
.promotionalOffer(offerID: "promo_winback", signature: jwsSignature)
|
|
]
|
|
)
|
|
```
|
|
|
|
#### From WWDC 2025-241:10:55
|
|
|
|
**Purchase with Custom Intro Eligibility**:
|
|
```swift
|
|
let jwsSignature: String // From your server
|
|
|
|
let result = try await product.purchase(
|
|
confirmIn: scene,
|
|
options: [
|
|
.introductoryOfferEligibility(signature: jwsSignature)
|
|
]
|
|
)
|
|
```
|
|
|
|
#### From WWDC 2025-241:10:42
|
|
|
|
**SwiftUI Purchase (Using Environment)**:
|
|
```swift
|
|
struct ProductView: View {
|
|
let product: Product
|
|
@Environment(\.purchase) private var purchase
|
|
|
|
var body: some View {
|
|
Button("Buy \(product.displayPrice)") {
|
|
Task {
|
|
do {
|
|
let result = try await purchase(product)
|
|
// Handle result
|
|
} catch {
|
|
print("Purchase failed: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:9:50
|
|
|
|
### PurchaseResult
|
|
|
|
**Handling Purchase Results**:
|
|
```swift
|
|
let result = try await product.purchase(confirmIn: scene)
|
|
|
|
switch result {
|
|
case .success(let verificationResult):
|
|
// Purchase succeeded - verify transaction
|
|
guard let transaction = try? verificationResult.payloadValue else {
|
|
print("Transaction verification failed")
|
|
return
|
|
}
|
|
|
|
// Grant entitlement
|
|
await grantEntitlement(for: transaction)
|
|
await transaction.finish()
|
|
|
|
case .userCancelled:
|
|
// User tapped "Cancel" in payment sheet
|
|
print("User cancelled purchase")
|
|
|
|
case .pending:
|
|
// Purchase requires action (Ask to Buy, payment issue)
|
|
// Transaction will arrive via Transaction.updates when approved
|
|
print("Purchase pending approval")
|
|
|
|
@unknown default:
|
|
break
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241
|
|
|
|
---
|
|
|
|
## Transaction
|
|
|
|
### Overview
|
|
|
|
`Transaction` represents a successful in-app purchase. Contains purchase metadata, product ID, purchase date, and for subscriptions, expiration date.
|
|
|
|
### New Fields (iOS 18.4)
|
|
|
|
**appTransactionID**:
|
|
```swift
|
|
let transaction: Transaction
|
|
let appTransactionID = transaction.appTransactionID
|
|
// Unique ID for app download (same across all purchases by same Apple Account)
|
|
```
|
|
|
|
#### From WWDC 2025-241:4:13
|
|
|
|
**offerPeriod**:
|
|
```swift
|
|
if let offerPeriod = transaction.offer?.period {
|
|
print("Offer duration: \(offerPeriod)")
|
|
// ISO 8601 duration format (e.g., "P1M" for 1 month)
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-249:3:11
|
|
|
|
**advancedCommerceInfo**:
|
|
```swift
|
|
if let advancedInfo = transaction.advancedCommerceInfo {
|
|
// Only present for Advanced Commerce API purchases
|
|
// nil for standard IAP
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:4:42
|
|
|
|
### Essential Properties
|
|
|
|
**Basic Fields**:
|
|
```swift
|
|
let transaction: Transaction
|
|
|
|
transaction.id // Unique transaction ID
|
|
transaction.originalID // Original transaction ID (consistent across renewals)
|
|
transaction.productID // "com.app.pro_monthly"
|
|
transaction.productType // .autoRenewable
|
|
transaction.purchaseDate // Date of purchase
|
|
transaction.appAccountToken // UUID set at purchase time (if provided)
|
|
```
|
|
|
|
**Subscription Fields**:
|
|
```swift
|
|
transaction.expirationDate // When subscription expires
|
|
transaction.isUpgraded // true if user upgraded to higher tier
|
|
transaction.revocationDate // Date of refund (nil if not refunded)
|
|
transaction.revocationReason // .developerIssue or .other
|
|
```
|
|
|
|
**Offer Fields**:
|
|
```swift
|
|
if let offer = transaction.offer {
|
|
offer.type // .introductory or .promotional or .code
|
|
offer.id // Offer identifier from App Store Connect
|
|
offer.paymentMode // .freeTrial, .payAsYouGo, .payUpFront, .oneTime
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:8:00
|
|
|
|
### Current Entitlements
|
|
|
|
**Get All Current Entitlements**:
|
|
```swift
|
|
var purchasedProductIDs: Set<String> = []
|
|
|
|
for await result in Transaction.currentEntitlements {
|
|
guard let transaction = try? result.payloadValue else {
|
|
continue
|
|
}
|
|
|
|
// Only include non-refunded transactions
|
|
if transaction.revocationDate == nil {
|
|
purchasedProductIDs.insert(transaction.productID)
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241
|
|
|
|
**Get Entitlements for Specific Product (iOS 18.4+)**:
|
|
```swift
|
|
let productID = "com.app.premium"
|
|
|
|
for await result in Transaction.currentEntitlements(for: productID) {
|
|
if let transaction = try? result.payloadValue,
|
|
transaction.revocationDate == nil {
|
|
// User owns this product
|
|
return true
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:3:31
|
|
|
|
**Deprecated API (iOS 18.4)**:
|
|
```swift
|
|
// ❌ Deprecated in iOS 18.4
|
|
let entitlement = await Transaction.currentEntitlement(for: productID)
|
|
|
|
// ✅ Use this instead (returns sequence, handles Family Sharing)
|
|
for await result in Transaction.currentEntitlements(for: productID) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:3:31
|
|
|
|
### Transaction History
|
|
|
|
**Get All Transactions**:
|
|
```swift
|
|
for await result in Transaction.all {
|
|
guard let transaction = try? result.payloadValue else {
|
|
continue
|
|
}
|
|
|
|
print("Transaction: \(transaction.productID) on \(transaction.purchaseDate)")
|
|
}
|
|
```
|
|
|
|
**Get Transactions for Product**:
|
|
```swift
|
|
for await result in Transaction.all(matching: productID) {
|
|
guard let transaction = try? result.payloadValue else {
|
|
continue
|
|
}
|
|
|
|
// All transactions for this product
|
|
}
|
|
```
|
|
|
|
### Transaction Listener
|
|
|
|
**Listen for Real-Time Updates (REQUIRED)**:
|
|
```swift
|
|
func listenForTransactions() -> Task<Void, Never> {
|
|
Task.detached {
|
|
for await verificationResult in Transaction.updates {
|
|
await handleTransaction(verificationResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleTransaction(_ result: VerificationResult<Transaction>) async {
|
|
guard let transaction = try? result.payloadValue else {
|
|
return
|
|
}
|
|
|
|
// Grant or revoke entitlement
|
|
if transaction.revocationDate != nil {
|
|
await revokeEntitlement(for: transaction.productID)
|
|
} else {
|
|
await grantEntitlement(for: transaction)
|
|
}
|
|
|
|
// CRITICAL: Always finish transaction
|
|
await transaction.finish()
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2021-10114
|
|
|
|
**Transaction Sources**:
|
|
- In-app purchases
|
|
- Purchases from App Store (promoted IAP)
|
|
- Offer code redemptions
|
|
- Subscription renewals
|
|
- Family Sharing transactions
|
|
- Pending purchases (Ask to Buy) that complete
|
|
- Refund notifications
|
|
|
|
### Verification
|
|
|
|
**VerificationResult**:
|
|
```swift
|
|
let result: VerificationResult<Transaction>
|
|
|
|
switch result {
|
|
case .verified(let transaction):
|
|
// ✅ Transaction signed by App Store
|
|
await grantEntitlement(for: transaction)
|
|
await transaction.finish()
|
|
|
|
case .unverified(let transaction, let error):
|
|
// ❌ Transaction signature invalid
|
|
print("Unverified: \(error)")
|
|
// DO NOT grant entitlement
|
|
await transaction.finish() // Still finish to clear queue
|
|
}
|
|
```
|
|
|
|
**What Verification Checks**:
|
|
- Transaction signed by App Store (not fraudulent)
|
|
- Transaction belongs to this app (bundle ID match)
|
|
- Transaction belongs to this device
|
|
|
|
### Finishing Transactions
|
|
|
|
**Always Call finish()**:
|
|
```swift
|
|
await transaction.finish()
|
|
```
|
|
|
|
**When to finish**:
|
|
- ✅ After granting entitlement to user
|
|
- ✅ After storing transaction receipt/ID
|
|
- ✅ Even for unverified transactions (to clear queue)
|
|
- ✅ Even for refunded transactions
|
|
|
|
**What happens if you don't finish**:
|
|
- Transaction redelivered on next app launch
|
|
- `Transaction.updates` re-emits transaction
|
|
- Queue builds up over time
|
|
|
|
---
|
|
|
|
## AppTransaction
|
|
|
|
### Overview
|
|
|
|
`AppTransaction` represents the original app download. Available via `AppTransaction.shared`.
|
|
|
|
### New Fields (iOS 18.4)
|
|
|
|
**appTransactionID**:
|
|
```swift
|
|
let appTransaction = try await AppTransaction.shared
|
|
|
|
switch appTransaction {
|
|
case .verified(let transaction):
|
|
let appTransactionID = transaction.appTransactionID
|
|
// Globally unique ID for this Apple Account + app
|
|
// Same value appears in Transaction and RenewalInfo
|
|
|
|
case .unverified(_, let error):
|
|
print("AppTransaction verification failed: \(error)")
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:1:42
|
|
|
|
**originalPlatform**:
|
|
```swift
|
|
if let appTransaction = try? await AppTransaction.shared.payloadValue {
|
|
let platform = appTransaction.originalPlatform
|
|
|
|
switch platform {
|
|
case .iOS:
|
|
print("Originally downloaded on iPhone/iPad")
|
|
case .macOS:
|
|
print("Originally downloaded on Mac")
|
|
case .tvOS:
|
|
print("Originally downloaded on Apple TV")
|
|
case .visionOS:
|
|
print("Originally downloaded on Vision Pro")
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:2:11
|
|
|
|
**Note**: Apps downloaded on watchOS show `originalPlatform = .iOS`
|
|
|
|
### Essential Properties
|
|
|
|
```swift
|
|
let appTransaction: AppTransaction
|
|
|
|
appTransaction.appVersion // "1.2.3"
|
|
appTransaction.originalAppVersion // "1.0.0"
|
|
appTransaction.originalPurchaseDate // First download date
|
|
appTransaction.bundleID // "com.company.app"
|
|
appTransaction.deviceVerification // UUID for device
|
|
appTransaction.deviceVerificationNonce // Nonce for verification
|
|
```
|
|
|
|
### Use Cases
|
|
|
|
**Check App Version**:
|
|
```swift
|
|
if let appTransaction = try? await AppTransaction.shared.payloadValue {
|
|
if appTransaction.appVersion != currentVersion {
|
|
// Prompt user to update
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:0:51
|
|
|
|
**Business Model Migration**:
|
|
```swift
|
|
// Moving from paid app to free app with IAP
|
|
if appTransaction.originalPlatform == .iOS,
|
|
appTransaction.originalPurchaseDate < migrationDate {
|
|
// User paid for app before migration - grant premium
|
|
await grantPremiumAccess()
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:2:32
|
|
|
|
---
|
|
|
|
## Product.SubscriptionInfo.RenewalInfo
|
|
|
|
### Overview
|
|
|
|
`RenewalInfo` provides information about auto-renewable subscription renewal state, including whether it will renew, expiration reason, and upcoming offers.
|
|
|
|
### New Fields (iOS 18.4)
|
|
|
|
**appTransactionID**:
|
|
```swift
|
|
let renewalInfo: RenewalInfo
|
|
let appTransactionID = renewalInfo.appTransactionID
|
|
```
|
|
|
|
#### From WWDC 2025-241:6:40
|
|
|
|
**offerPeriod**:
|
|
```swift
|
|
if let offerPeriod = renewalInfo.offerPeriod {
|
|
print("Next renewal offer period: \(offerPeriod)")
|
|
// ISO 8601 duration (applies at next renewal)
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-249:3:11
|
|
|
|
**appAccountToken**:
|
|
```swift
|
|
if let token = renewalInfo.appAccountToken {
|
|
// UUID associating subscription with your server account
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:6:56
|
|
|
|
**advancedCommerceInfo**:
|
|
```swift
|
|
if let advancedInfo = renewalInfo.advancedCommerceInfo {
|
|
// Only for Advanced Commerce API subscriptions
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:6:50
|
|
|
|
### Essential Properties
|
|
|
|
**Renewal State**:
|
|
```swift
|
|
let renewalInfo: RenewalInfo
|
|
|
|
renewalInfo.willAutoRenew // true if subscription will renew
|
|
renewalInfo.autoRenewPreference // Product ID customer will renew to
|
|
renewalInfo.expirationReason // Why subscription expired (if expired)
|
|
```
|
|
|
|
**Expiration Reasons**:
|
|
```swift
|
|
switch renewalInfo.expirationReason {
|
|
case .autoRenewDisabled:
|
|
// User turned off auto-renewal
|
|
case .billingError:
|
|
// Payment method issue
|
|
case .didNotConsentToPriceIncrease:
|
|
// User didn't accept price increase - show win-back offer!
|
|
case .productUnavailable:
|
|
// Product no longer available
|
|
case .unknown:
|
|
// Unknown reason
|
|
@unknown default:
|
|
break
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:5:38
|
|
|
|
**Grace Period**:
|
|
```swift
|
|
if let gracePeriodExpiration = renewalInfo.gracePeriodExpirationDate {
|
|
// Subscription in grace period - billing issue
|
|
// Show update payment method UI
|
|
}
|
|
```
|
|
|
|
**Price Increase Consent**:
|
|
```swift
|
|
if let consentStatus = renewalInfo.priceIncreaseStatus {
|
|
switch consentStatus {
|
|
case .agreed:
|
|
// User accepted price increase
|
|
case .notYetResponded:
|
|
// User hasn't responded - show consent UI
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
### Accessing RenewalInfo
|
|
|
|
**From SubscriptionStatus**:
|
|
```swift
|
|
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
|
|
|
|
for status in statuses {
|
|
switch status.renewalInfo {
|
|
case .verified(let renewalInfo):
|
|
print("Will renew: \(renewalInfo.willAutoRenew)")
|
|
case .unverified(_, let error):
|
|
print("Renewal info verification failed: \(error)")
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Product.SubscriptionInfo.Status
|
|
|
|
### Overview
|
|
|
|
`SubscriptionStatus` represents the current state of an auto-renewable subscription, including whether it's active, expired, in grace period, or in billing retry.
|
|
|
|
### Subscription States
|
|
|
|
**State Enum**:
|
|
```swift
|
|
let status: Product.SubscriptionInfo.Status
|
|
|
|
switch status.state {
|
|
case .subscribed:
|
|
// User has active subscription - full access
|
|
|
|
case .expired:
|
|
// Subscription expired - show resubscribe/win-back offer
|
|
|
|
case .inGracePeriod:
|
|
// Billing issue but access maintained - show update payment UI
|
|
|
|
case .inBillingRetryPeriod:
|
|
// Apple retrying payment - maintain access
|
|
|
|
case .revoked:
|
|
// Family Sharing access removed - revoke access
|
|
|
|
@unknown default:
|
|
break
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241
|
|
|
|
### Getting Subscription Status
|
|
|
|
**For Subscription Group**:
|
|
```swift
|
|
let groupID = "pro_tier"
|
|
|
|
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
|
|
|
|
// Find highest service level
|
|
let activeStatus = statuses
|
|
.filter { $0.state == .subscribed }
|
|
.max { $0.transaction.productID < $1.transaction.productID }
|
|
```
|
|
|
|
#### From WWDC 2025-241:6:22
|
|
|
|
**For Specific Transaction (iOS 18.4+)**:
|
|
```swift
|
|
let transactionID = transaction.id
|
|
|
|
let status = try await Product.SubscriptionInfo.status(for: transactionID)
|
|
```
|
|
|
|
#### From WWDC 2025-241:6:40
|
|
|
|
**Listen for Status Updates**:
|
|
```swift
|
|
for await statuses in Product.SubscriptionInfo.Status.updates(for: groupID) {
|
|
// Process updated statuses
|
|
for status in statuses {
|
|
print("Status: \(status.state)")
|
|
}
|
|
}
|
|
```
|
|
|
|
### Status Properties
|
|
|
|
```swift
|
|
let status: Product.SubscriptionInfo.Status
|
|
|
|
status.state // .subscribed, .expired, etc.
|
|
status.transaction // VerificationResult<Transaction>
|
|
status.renewalInfo // VerificationResult<RenewalInfo>
|
|
```
|
|
|
|
---
|
|
|
|
## StoreKit Views
|
|
|
|
### ProductView (iOS 17+)
|
|
|
|
**Basic Usage**:
|
|
```swift
|
|
import StoreKit
|
|
|
|
struct ContentView: View {
|
|
let productID = "com.app.premium"
|
|
|
|
var body: some View {
|
|
ProductView(id: productID)
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2023-10013
|
|
|
|
**With Loaded Product**:
|
|
```swift
|
|
struct ContentView: View {
|
|
let product: Product
|
|
|
|
var body: some View {
|
|
ProductView(for: product)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Custom Icon**:
|
|
```swift
|
|
ProductView(id: productID) {
|
|
Image(systemName: "star.fill")
|
|
.foregroundStyle(.yellow)
|
|
}
|
|
```
|
|
|
|
**Control Styles**:
|
|
```swift
|
|
ProductView(id: productID)
|
|
.productViewStyle(.regular) // Default
|
|
|
|
ProductView(id: productID)
|
|
.productViewStyle(.compact) // Smaller
|
|
|
|
ProductView(id: productID)
|
|
.productViewStyle(.large) // Prominent
|
|
```
|
|
|
|
### StoreView (iOS 17+)
|
|
|
|
**Basic Store**:
|
|
```swift
|
|
struct ContentView: View {
|
|
let productIDs = [
|
|
"com.app.coins_100",
|
|
"com.app.coins_500",
|
|
"com.app.coins_1000"
|
|
]
|
|
|
|
var body: some View {
|
|
StoreView(ids: productIDs)
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2023-10013
|
|
|
|
**With Loaded Products**:
|
|
```swift
|
|
struct ContentView: View {
|
|
let products: [Product]
|
|
|
|
var body: some View {
|
|
StoreView(products: products)
|
|
}
|
|
}
|
|
```
|
|
|
|
### SubscriptionStoreView (iOS 17+)
|
|
|
|
**Basic Subscription Store**:
|
|
```swift
|
|
struct SubscriptionView: View {
|
|
let groupID = "pro_tier"
|
|
|
|
var body: some View {
|
|
SubscriptionStoreView(groupID: groupID) {
|
|
// Marketing content above subscription options
|
|
VStack {
|
|
Image("app-icon")
|
|
Text("Go Pro")
|
|
.font(.largeTitle.bold())
|
|
Text("Unlock all features")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2023-10013
|
|
|
|
**Control Style**:
|
|
```swift
|
|
SubscriptionStoreView(groupID: groupID) {
|
|
// Marketing content
|
|
}
|
|
.subscriptionStoreControlStyle(.automatic) // Default
|
|
.subscriptionStoreControlStyle(.picker) // Horizontal picker
|
|
.subscriptionStoreControlStyle(.buttons) // Stacked buttons
|
|
.subscriptionStoreControlStyle(.prominentPicker) // Large picker (iOS 18.4+)
|
|
```
|
|
|
|
#### From WWDC 2025-241
|
|
|
|
### SubscriptionOfferView (iOS 18.4+)
|
|
|
|
**Basic Offer View**:
|
|
```swift
|
|
struct ContentView: View {
|
|
let productID = "com.app.pro_monthly"
|
|
|
|
var body: some View {
|
|
SubscriptionOfferView(id: productID)
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:14:27
|
|
|
|
**With Loaded Product**:
|
|
```swift
|
|
let product: Product // Already loaded via Product.products(for:)
|
|
|
|
SubscriptionOfferView(product: product)
|
|
```
|
|
|
|
**With Promotional Icon**:
|
|
```swift
|
|
SubscriptionOfferView(
|
|
id: productID,
|
|
prefersPromotionalIcon: true
|
|
)
|
|
|
|
// Also available as modifier
|
|
SubscriptionOfferView(id: productID)
|
|
.prefersPromotionalIcon(true)
|
|
```
|
|
|
|
**With Custom Icon**:
|
|
```swift
|
|
SubscriptionOfferView(id: productID) {
|
|
Image("custom-icon")
|
|
.resizable()
|
|
.frame(width: 60, height: 60)
|
|
} placeholder: {
|
|
Image(systemName: "photo")
|
|
.foregroundStyle(.gray)
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:15:14
|
|
|
|
**With Detail Action**:
|
|
```swift
|
|
@State private var showStore = false
|
|
|
|
var body: some View {
|
|
SubscriptionOfferView(id: productID)
|
|
.subscriptionOfferViewDetailAction {
|
|
showStore = true
|
|
}
|
|
.sheet(isPresented: $showStore) {
|
|
SubscriptionStoreView(groupID: "pro_tier")
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:15:38
|
|
|
|
**Visible Relationship**:
|
|
```swift
|
|
// Only show if customer can upgrade
|
|
SubscriptionOfferView(
|
|
groupID: "pro_tier",
|
|
visibleRelationship: .upgrade
|
|
)
|
|
|
|
// Only show if customer can downgrade
|
|
SubscriptionOfferView(
|
|
groupID: "pro_tier",
|
|
visibleRelationship: .downgrade
|
|
)
|
|
|
|
// Show crossgrade options (same tier, different billing period)
|
|
SubscriptionOfferView(
|
|
groupID: "pro_tier",
|
|
visibleRelationship: .crossgrade
|
|
)
|
|
|
|
// Show current subscription (only if offer available)
|
|
SubscriptionOfferView(
|
|
groupID: "pro_tier",
|
|
visibleRelationship: .current
|
|
)
|
|
|
|
// Show any plan in group
|
|
SubscriptionOfferView(
|
|
groupID: "pro_tier",
|
|
visibleRelationship: .all
|
|
)
|
|
```
|
|
|
|
#### From WWDC 2025-241:17:44
|
|
|
|
**With App Icon**:
|
|
```swift
|
|
SubscriptionOfferView(
|
|
groupID: groupID,
|
|
visibleRelationship: .all,
|
|
useAppIcon: true
|
|
)
|
|
```
|
|
|
|
#### From WWDC 2025-241:19:06
|
|
|
|
### Offer Modifiers
|
|
|
|
**Promotional Offer (JWS)**:
|
|
```swift
|
|
SubscriptionStoreView(groupID: groupID)
|
|
.subscriptionPromotionalOffer(
|
|
for: { subscription in
|
|
// Return offer for this subscription
|
|
return subscription.promotionalOffers.first
|
|
},
|
|
signature: { subscription, offer in
|
|
// Get JWS signature from server
|
|
let signature = try await server.signOffer(
|
|
productID: subscription.id,
|
|
offerID: offer.id
|
|
)
|
|
return signature
|
|
}
|
|
)
|
|
```
|
|
|
|
#### From WWDC 2025-241:12:17
|
|
|
|
### subscriptionStatusTask Modifier (iOS 18.4+)
|
|
|
|
Track subscription status at the app level with a SwiftUI modifier. Eliminates manual polling by reacting to status changes automatically.
|
|
|
|
**Basic Usage**:
|
|
```swift
|
|
@main
|
|
struct MyApp: App {
|
|
@State private var customerStatus: CustomerStatus = .unknown
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
ContentView()
|
|
.environment(\.customerSubscriptionStatus, customerStatus)
|
|
.subscriptionStatusTask(for: "your.group.id") { statuses in
|
|
if statuses.contains(where: { $0.state == .subscribed }) {
|
|
customerStatus = .subscribed
|
|
} else if statuses.contains(where: { $0.state == .expired }) {
|
|
customerStatus = .expired
|
|
} else {
|
|
customerStatus = .notSubscribed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key behavior**:
|
|
- Fires on app launch with current statuses
|
|
- Fires again when subscription status changes (renewal, expiration, upgrade)
|
|
- Translate StoreKit statuses to your app's model — keep your domain model simple
|
|
- Attach at the top of your view hierarchy (App or root WindowGroup)
|
|
|
|
---
|
|
|
|
## Offer Codes (iOS 18.2+)
|
|
|
|
### Overview
|
|
|
|
Offer codes now support all product types (previously subscription-only):
|
|
- Consumables
|
|
- Non-consumables
|
|
- Non-renewing subscriptions
|
|
- Auto-renewable subscriptions
|
|
|
|
### Redeem in App
|
|
|
|
**UIKit**:
|
|
```swift
|
|
func showOfferCodeSheet() {
|
|
guard let scene = view.window?.windowScene else { return }
|
|
|
|
StoreKit.AppStore.presentOfferCodeRedeemSheet(in: scene)
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:7:38
|
|
|
|
**SwiftUI**:
|
|
```swift
|
|
.offerCodeRedemption(isPresented: $showRedeemSheet)
|
|
```
|
|
|
|
### Payment Mode
|
|
|
|
**New: .oneTime**:
|
|
```swift
|
|
let transaction: Transaction
|
|
|
|
if let offer = transaction.offer {
|
|
switch offer.paymentMode {
|
|
case .freeTrial:
|
|
// No charge during offer period
|
|
case .payAsYouGo:
|
|
// Discounted price per billing period
|
|
case .payUpFront:
|
|
// One-time discounted price for entire duration
|
|
case .oneTime:
|
|
// ✨ New: One-time offer code redemption (iOS 17.2+)
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:8:17
|
|
|
|
**Legacy Access (iOS 15-17.1)**:
|
|
```swift
|
|
if let offerMode = transaction.offerPaymentModeStringRepresentation {
|
|
// String representation for older OS versions
|
|
print(offerMode) // "oneTime"
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:8:49
|
|
|
|
---
|
|
|
|
## App Store Server Library
|
|
|
|
### Overview
|
|
|
|
Open-source library for signing IAP requests and decoding server API responses. Available in Swift, Java, Python, Node.js.
|
|
|
|
### Create Promotional Offer Signature
|
|
|
|
**Swift Example**:
|
|
```swift
|
|
import AppStoreServerLibrary
|
|
|
|
// Configure signing
|
|
let signingKey = "YOUR_PRIVATE_KEY"
|
|
let keyID = "YOUR_KEY_ID"
|
|
let issuerID = "YOUR_ISSUER_ID"
|
|
let bundleID = "com.app.bundle"
|
|
|
|
let creator = PromotionalOfferV2SignatureCreator(
|
|
privateKey: signingKey,
|
|
keyID: keyID,
|
|
issuerID: issuerID,
|
|
bundleID: bundleID
|
|
)
|
|
|
|
// Create signature
|
|
let productID = "com.app.pro_monthly"
|
|
let offerID = "promo_winback"
|
|
let transactionID = transaction.id // Optional but recommended
|
|
|
|
let signature = try creator.createSignature(
|
|
productIdentifier: productID,
|
|
subscriptionOfferIdentifier: offerID,
|
|
applicationUsername: nil,
|
|
nonce: UUID(),
|
|
timestamp: Date().timeIntervalSince1970,
|
|
transactionIdentifier: transactionID
|
|
)
|
|
|
|
// Send signature to app
|
|
return signature // Compact JWS string
|
|
```
|
|
|
|
#### From WWDC 2025-241:12:44, 2025-249
|
|
|
|
**Server Endpoint Example**:
|
|
```swift
|
|
app.get("promo-offer") { req async throws -> String in
|
|
let productID = try req.query.get(String.self, at: "productID")
|
|
let offerID = try req.query.get(String.self, at: "offerID")
|
|
|
|
let signature = try creator.createSignature(
|
|
productIdentifier: productID,
|
|
subscriptionOfferIdentifier: offerID,
|
|
transactionIdentifier: nil
|
|
)
|
|
|
|
return signature
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:12:52
|
|
|
|
---
|
|
|
|
## App Store Server API
|
|
|
|
### Set App Account Token
|
|
|
|
**Endpoint**:
|
|
```
|
|
PATCH /inApps/v1/transactions/{originalTransactionId}
|
|
```
|
|
|
|
**Request Body**:
|
|
```json
|
|
{
|
|
"appAccountToken": "550e8400-e29b-41d4-a716-446655440000"
|
|
}
|
|
```
|
|
|
|
**Usage**:
|
|
- Set appAccountToken for purchases made outside your app (offer codes, App Store)
|
|
- Update appAccountToken when account ownership changes
|
|
- Associates transaction with customer account on your server
|
|
|
|
#### From WWDC 2025-249:5:19
|
|
|
|
### Get App Transaction Info
|
|
|
|
**Endpoint**:
|
|
```
|
|
GET /inApps/v2/appTransaction/{transactionId}
|
|
```
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"signedAppTransactionInfo": "eyJhbGc..."
|
|
}
|
|
```
|
|
|
|
**Usage**:
|
|
- Get app download information on server
|
|
- Check app version, platform, environment
|
|
- Available later in 2025
|
|
|
|
#### From WWDC 2025-249:10:48
|
|
|
|
### Send Consumption Information V2
|
|
|
|
**Endpoint**:
|
|
```
|
|
PUT /inApps/v2/transactions/consumption/{transactionId}
|
|
```
|
|
|
|
**Request Body**:
|
|
```json
|
|
{
|
|
"customerConsented": true,
|
|
"sampleContentProvided": false,
|
|
"deliveryStatus": "DELIVERED",
|
|
"refundPreference": "GRANT_PRORATED",
|
|
"consumptionPercentage": 25000
|
|
}
|
|
```
|
|
|
|
**Fields**:
|
|
- `customerConsented` (required): User consented to send consumption data
|
|
- `sampleContentProvided` (optional): Sample provided before purchase
|
|
- `deliveryStatus` (required): "DELIVERED" or various UNDELIVERED statuses
|
|
- `refundPreference` (optional): "NO_REFUND", "GRANT_REFUND", "GRANT_PRORATED"
|
|
- `consumptionPercentage` (optional): 0-100000 (millipercent, e.g., 25000 = 25%)
|
|
|
|
**Prorated Refund**:
|
|
- New in 2025
|
|
- Supports partial consumption (consumables, non-consumables, non-renewing)
|
|
- For auto-renewable subscriptions, App Store calculates based on time remaining
|
|
|
|
#### From WWDC 2025-249:16:09
|
|
|
|
### Refund Notifications
|
|
|
|
**REFUND Notification**:
|
|
```json
|
|
{
|
|
"notificationType": "REFUND",
|
|
"data": {
|
|
"signedTransactionInfo": "...",
|
|
"refundPercentage": 75,
|
|
"revocationType": "REFUND_PRORATED"
|
|
}
|
|
}
|
|
```
|
|
|
|
**revocationType Values**:
|
|
- `REFUND_FULL`: 100% refund - revoke all access
|
|
- `REFUND_PRORATED`: Partial refund - revoke proportional access
|
|
- `FAMILY_REVOKE`: Family Sharing removed - revoke access
|
|
|
|
#### From WWDC 2025-249:20:17
|
|
|
|
---
|
|
|
|
## Edge Cases
|
|
|
|
### Family Sharing
|
|
|
|
**Detect Family Shared Transactions**:
|
|
```swift
|
|
// appAccountToken is NOT available for family shared transactions
|
|
let transaction: Transaction
|
|
|
|
if transaction.appAccountToken == nil {
|
|
// Might be family shared (or appAccountToken not set)
|
|
// Check ownershipType (if available)
|
|
}
|
|
```
|
|
|
|
**Subscription Status for Family Sharing**:
|
|
```swift
|
|
// Each family member has unique appTransactionID
|
|
// Use appTransactionID to identify individual family members
|
|
```
|
|
|
|
#### From WWDC 2025-241:1:54
|
|
|
|
### Refunds
|
|
|
|
**Handle Refund**:
|
|
```swift
|
|
func handleTransaction(_ transaction: Transaction) async {
|
|
if let revocationDate = transaction.revocationDate {
|
|
// Transaction was refunded
|
|
print("Refunded on \(revocationDate)")
|
|
|
|
switch transaction.revocationReason {
|
|
case .developerIssue:
|
|
// Refund due to app issue
|
|
case .other:
|
|
// Other refund reason
|
|
@unknown default:
|
|
break
|
|
}
|
|
|
|
// Revoke entitlement
|
|
await revokeEntitlement(for: transaction.productID)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Advanced Commerce API
|
|
|
|
The Advanced Commerce API enables support for:
|
|
- In-app purchases for large content catalogs
|
|
- Creator experiences (tipping, patronage)
|
|
- Subscriptions with optional add-ons
|
|
|
|
**Check if Transaction Uses Advanced Commerce**:
|
|
```swift
|
|
if transaction.advancedCommerceInfo != nil {
|
|
// Transaction from Advanced Commerce API
|
|
// Large catalogs, creator experiences, subscriptions with add-ons
|
|
}
|
|
```
|
|
|
|
Accessible through the `advancedCommerceInfo` field on both `Transaction` and `RenewalInfo`. Returns `nil` for standard IAP transactions.
|
|
|
|
#### From WWDC 2025-241:4:51
|
|
|
|
### Win-Back Offers
|
|
|
|
**Show Win-Back for Expired Subscription**:
|
|
```swift
|
|
let renewalInfo: RenewalInfo
|
|
|
|
if renewalInfo.expirationReason == .didNotConsentToPriceIncrease {
|
|
// Perfect time for win-back offer!
|
|
SubscriptionOfferView(
|
|
groupID: groupID,
|
|
visibleRelationship: .current
|
|
)
|
|
.preferredSubscriptionOffer(offer: winBackOffer)
|
|
}
|
|
```
|
|
|
|
#### From WWDC 2025-241:5:38
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### StoreKit Configuration File
|
|
|
|
**Create**:
|
|
1. Xcode → File → New → StoreKit Configuration File
|
|
2. Add products (consumables, non-consumables, subscriptions)
|
|
3. Configure prices, images, descriptions
|
|
|
|
**Enable in Scheme**:
|
|
1. Scheme → Edit Scheme → Run → Options
|
|
2. StoreKit Configuration: Select .storekit file
|
|
|
|
**Test Scenarios**:
|
|
- Successful purchases
|
|
- Cancelled purchases
|
|
- Subscription renewals (accelerated time)
|
|
- Subscription expirations
|
|
- Upgrades/downgrades
|
|
- Offer code redemptions
|
|
- Family Sharing (enable in config file)
|
|
|
|
### Transaction Manager
|
|
|
|
Use the Transaction Manager window in Xcode to inspect and manipulate transactions during testing:
|
|
|
|
- Create transactions manually (test specific purchase flows)
|
|
- Modify transaction properties (expiration, renewal state)
|
|
- Test subscription offer scenarios
|
|
- Inspect transaction details and verification status
|
|
|
|
**Open**: Debug → StoreKit → Manage Transactions (while running with StoreKit configuration)
|
|
|
|
### Sandbox Testing
|
|
|
|
**Create Sandbox Account**:
|
|
1. App Store Connect → Users and Access → Sandbox Testers
|
|
2. Create test Apple ID
|
|
3. Sign in on device Settings → App Store → Sandbox Account
|
|
|
|
**Clear Purchase History**:
|
|
- Settings → App Store → Sandbox Account → Clear Purchase History
|
|
|
|
---
|
|
|
|
## Migration from StoreKit 1
|
|
|
|
### Key Changes
|
|
|
|
**Delegates → Async/Await**:
|
|
```swift
|
|
// StoreKit 1
|
|
class StoreObserver: NSObject, SKPaymentTransactionObserver {
|
|
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
|
|
// Handle transactions
|
|
}
|
|
}
|
|
|
|
// StoreKit 2
|
|
for await result in Transaction.updates {
|
|
// Handle transactions
|
|
}
|
|
```
|
|
|
|
**Receipt → Transaction**:
|
|
```swift
|
|
// StoreKit 1
|
|
let receiptURL = Bundle.main.appStoreReceiptURL
|
|
let receipt = try Data(contentsOf: receiptURL!)
|
|
|
|
// StoreKit 2
|
|
let transaction: Transaction // Automatically verified!
|
|
```
|
|
|
|
**Products → Product.products(for:)**:
|
|
```swift
|
|
// StoreKit 1
|
|
let request = SKProductsRequest(productIdentifiers: Set(productIDs))
|
|
request.delegate = self
|
|
request.start()
|
|
|
|
// StoreKit 2
|
|
let products = try await Product.products(for: productIDs)
|
|
```
|
|
|
|
---
|
|
|
|
## Resources
|
|
|
|
**WWDC**: 2025-241, 2025-249, 2024-10061, 2024-10062, 2024-10110, 2023-10013, 2023-10140, 2022-10007, 2022-110404, 2021-10114
|
|
|
|
**Docs**: /storekit
|
|
|
|
**Skills**: axiom-in-app-purchases
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
### Product Types
|
|
- `.consumable` - Can purchase multiple times (coins, boosts)
|
|
- `.nonConsumable` - Purchase once, own forever (premium, level packs)
|
|
- `.autoRenewable` - Auto-renewing subscriptions
|
|
- `.nonRenewing` - Fixed duration subscriptions
|
|
|
|
### Transaction States
|
|
- `success` - Purchase completed
|
|
- `userCancelled` - User tapped cancel
|
|
- `pending` - Requires action (Ask to Buy)
|
|
|
|
### Subscription States
|
|
- `.subscribed` - Active subscription
|
|
- `.expired` - Subscription ended
|
|
- `.inGracePeriod` - Billing issue, access maintained
|
|
- `.inBillingRetryPeriod` - Apple retrying payment
|
|
- `.revoked` - Family Sharing removed
|
|
|
|
### Essential Calls
|
|
```swift
|
|
// Load products
|
|
try await Product.products(for: productIDs)
|
|
|
|
// Purchase
|
|
try await product.purchase(confirmIn: scene)
|
|
|
|
// Current entitlements
|
|
Transaction.currentEntitlements(for: productID)
|
|
|
|
// Transaction listener
|
|
Transaction.updates
|
|
|
|
// Subscription status
|
|
Product.SubscriptionInfo.status(for: groupID)
|
|
|
|
// Restore purchases
|
|
try await AppStore.sync()
|
|
|
|
// Finish transaction (REQUIRED)
|
|
await transaction.finish()
|
|
```
|