Skip to content
Shiny.NET

In-App Purchases

NuGet package Shiny.AppDeviceBridge.InAppPurchases

The in-app purchases bridge lets the page sell consumables, non-consumables and auto-renewable subscriptions through the device’s own store: the App Store (StoreKit 2) on iOS and Google Play Billing on Android. It’s built on Shiny.Mobile.InAppPurchases 5.9. The store’s sheet takes the payment, so the page never sees card details. This is store purchasing, not Apple Pay or Google Pay.

Platform Backed by
iOS, iPadOS 15+ StoreKit 2
Android (API 26+) Google Play Billing 9
Mac Catalyst, macOS, Windows, Linux nothing: every route answers 501
Terminal window
dotnet add package Shiny.AppDeviceBridge.InAppPurchases
builder.UseAppDeviceBridge(
bridge => bridge.AddInAppPurchasesBridge(),
webApp => { /* … */ }
);

AddInAppPurchasesBridge() registers Shiny’s purchase manager with the bridge’s delegate, so there’s nothing else to call. An app that already calls services.AddInAppPurchases<MyPurchaseDelegate>() keeps its delegate, and Shiny runs both. The products themselves are configured in App Store Connect and the Play Console. See Store Setup.

// Program.cs (Blazor WebAssembly)
builder.Services.AddWebAppHostClient().AddInAppPurchasesBridgeClient();
@inject IInAppPurchasesBridge Purchases
var products = await Purchases.GetProductsAsync(new ProductsRequest(["coins_100", "pro_monthly"]));
var result = await Purchases.PurchaseAsync(new PurchaseRequest("pro_monthly", AccountToken: user.Id));
if (result is { Status: PurchaseResultStatus.Success, Purchase: { } purchase })
{
await api.VerifyAndGrantAsync(purchase.VerificationData, purchase.ProductId); // your server
await Purchases.FinishAsync(purchase.TransactionId, new FinishPurchaseRequest(Consume: false));
}
import { InAppPurchasesBridge } from "@shinyorg/appdevicebridge";
const purchases = new InAppPurchasesBridge();
const result = await purchases.purchase({ productId: "coins_100", accountToken: user.id });
if (result.status === "Success" && result.purchase) {
await verifyAndGrant(result.purchase.verificationData, result.purchase.productId); // your server
await purchases.finish(result.purchase.transactionId, { consume: true });
}

Every route, its body and its response is listed under Requests.

  • Results, not errors: a purchase ends Success, Pending (Ask to Buy or a slow payment method), Cancelled or, on Google Play, AlreadyOwned. Never grant a pending purchase. It arrives as purchases.updated when it resolves.
  • Always send accountToken, your user’s id as a GUID. The stores echo it back as Apple’s appAccountToken and Google’s obfuscatedAccountId, on the device and in their server notifications.
  • Offers: subscriptionOffers lists Google’s base plans and offers, and Apple’s introductory offer when the user is eligible. Buy a Google offer by passing its offerToken. A Google upgrade or downgrade passes replacement with the old product and its purchase token. Apple handles plan changes within a subscription group by itself.

A paid purchase is not finished by the bridge. Send verificationData (Apple’s signed JWS, Google’s purchase token) to your server. Shiny.Mobile.InAppPurchases.Server’s IPurchaseVerifier takes it as it is. Store the entitlement, and only then call POST purchases/transactions/{transactionId}/finish with { consume }. consume: true makes a consumable buyable again. Google Play refunds a purchase that isn’t finished within three days.

The page only names the purchase. The bridge finds it among the store’s unfinished purchases and entitlements, so a page can’t finish a purchase the store doesn’t have. It answers 404 purchase_not_found when there’s no match, which usually means the purchase was finished already.

At startup, read GET purchases/unfinished and run each purchase through the same path, so a crash between paying and finishing never loses one. GET purchases/entitlements is what the user owns now. POST purchases/restore syncs with the App Store, which may ask the user to sign in, so call it only from a “Restore Purchases” button. POST purchases/manage opens the store’s subscription screen.

Purchases change outside a purchase call: Ask to Buy approvals, pending payments that complete, renewals, refunds (state: "Revoked"), purchases on another device and offer codes. Shiny listens from launch, and each change arrives as purchases.updated. It runs in the page if one is listening, and otherwise in background.js, so a purchase that completes with no page open is still verified and finished:

background.js
appdevicebridge.on("purchases.updated", async purchase => {
if (purchase.state !== "Purchased" || purchase.isFinished)
return;
await fetch("https://api.example.com/purchases/verify", { method: "POST", body: JSON.stringify(purchase) });
await fetch(`/_bridge/purchases/transactions/${encodeURIComponent(purchase.transactionId)}/finish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ consume: purchase.productId === "coins_100" })
});
});

The same purchase can arrive more than once, until it’s finished, so key on transactionId.

Status and code When
501 not_supported not iOS or Android
400 a body without productIds or productId, a quantity below 1, an incomplete replacement, or a finish without a body
404 purchase_not_found finishing a transaction the store has no unfinished purchase or entitlement for
503 store_unavailable, 503 network the store can’t be reached
403 not_allowed payments are blocked: parental controls, MDM, an unsupported country
404 product_not_found the store doesn’t know the product
409 product_unavailable, 409 no_user_interface, 409 invalid_state the product can’t be bought now, no window can show the sheet, or the purchase can’t be finished yet (a pending one)
400 developer_error the store rejected the request’s arguments, such as an invalid offer
502 verification_failed, 502 purchase_failed the store’s signature didn’t verify on the device, or any other store failure

The store’s own error code, when there is one, is at the end of the message.

GET/_bridge/purchases#

Which store this device buys through, and whether this user may make payments right now.

C#IInAppPurchasesBridge.GetStatusAsyncTSInAppPurchasesBridge.getStatus
Response
200 PurchasesStatus
PurchasesStatus · 2 fields
platformStorePlatformApp Store or Google Play.
canMakePaymentsbooleanFalse when the store is unreachable or payments are blocked — parental controls, MDM, an unsupported country, no Play Store.
POST/_bridge/purchases/products#

Localized products and prices. Ids the store does not know are left out rather than failing.

C#IInAppPurchasesBridge.GetProductsAsyncTSInAppPurchasesBridge.getProducts
Body
application/json · ProductsRequest
ProductsRequest · 1 field
productIdsstring[]Store product ids, as configured in App Store Connect or the Play Console.
Response
200 StoreProduct[]
StoreProduct · 9 fields
idstringThe store product id.
typeProductTypeOne-time purchase or subscription.
titlestringThe localized name.
descriptionstringThe localized description.
displayPricestringThe localized, formatted price — for a subscription, the recurring price of its default plan.
pricenumberThe price as a number, in currencyCode.
currencyCodestringISO 4217 currency code.
subscriptionOffersSubscriptionOffer[]Google Play: one per base plan and per offer the user is eligible for; buy one with its offerToken. Apple: the introductory offer when the user is eligible, then promotional offers.
subscriptionGroupIdstring | nullApple's subscription group; null on Google Play and for one-time products.
POST/_bridge/purchases/purchase#

Opens the store's purchase sheet. A cancelled sheet, a pending payment (Ask to Buy, a slow payment method) and a product already owned are results, not errors. A Success is not finished: verify and grant it, then call finish.

C#IInAppPurchasesBridge.PurchaseAsyncTSInAppPurchasesBridge.purchase
Body
application/json · PurchaseRequest
PurchaseRequest · 5 fields
productIdstringThe store product id.
accountTokenstring | nullYour user's id. It comes back on every transaction and in the stores' server notifications (Apple appAccountToken, Google obfuscatedAccountId), so your server can tell whose purchase it is. Always set it.
offerTokenstring | nullGoogle Play: the offerToken to buy; the first base plan when omitted. Ignored on Apple.
quantitynumberApple: how many units of a consumable, 1 to 10. Google lets the user choose in the sheet when the product allows it.
replacementSubscriptionReplacement | nullGoogle Play: the subscription this one replaces. Ignored on Apple.
Response
200 PurchaseResult
PurchaseResult · 2 fields
statusPurchaseResultStatusWhat happened.
purchasePurchase | nullSet on Success, and on Pending when the store reports one.
GET/_bridge/purchases/entitlements#

What the user owns now: non-consumables, active subscriptions, and consumables not yet finished.

C#IInAppPurchasesBridge.GetEntitlementsAsyncTSInAppPurchasesBridge.getEntitlements
Response
200 Purchase[]
Purchase · 17 fields
platformStorePlatformThe store it was made through.
productIdstringThe store product id.
transactionIdstringUnique per charge — finish it by this. Apple: the transaction id, new on each renewal. Google: the order id, or the purchase token while the purchase is pending.
originalTransactionIdstringStable across renewals. Apple: the original transaction id. Google: the purchase token.
statePurchaseStatePurchased, pending or revoked.
purchaseDatestringWhen it — or this renewal — was bought.
expirationDatestring | nullApple subscriptions only; Google does not report expiry on the device, so ask your server.
isFinishedbooleanApple: finished. Google: acknowledged or consumed.
isAutoRenewingbooleanA subscription set to renew.
quantitynumberUnits bought.
accountTokenstring | nullThe accountToken it was bought with.
verificationDatastringSend this to your server to verify the purchase. Apple: the signed JWS transaction. Google: the purchase token. Shiny.Mobile.InAppPurchases.Server's IPurchaseVerifier takes it as it is.
appIdstringThe bundle id (Apple) or package name (Google).
environmentStoreEnvironmentThe store environment; always Unknown on Google Play.
originalJsonstring | nullThe store's raw JSON for the transaction.
signaturestring | nullGoogle Play: the RSA signature of originalJson. Null on Apple.
revocationDatestring | nullApple: when it was refunded or revoked.
GET/_bridge/purchases/unfinished#

Purchases that were paid for but never finished — check at startup, and run each through the same verify, grant, finish path, so a crash between payment and finishing never loses a purchase.

C#IInAppPurchasesBridge.GetUnfinishedAsyncTSInAppPurchasesBridge.getUnfinished
Response
200 Purchase[]
Purchase · 17 fields
platformStorePlatformThe store it was made through.
productIdstringThe store product id.
transactionIdstringUnique per charge — finish it by this. Apple: the transaction id, new on each renewal. Google: the order id, or the purchase token while the purchase is pending.
originalTransactionIdstringStable across renewals. Apple: the original transaction id. Google: the purchase token.
statePurchaseStatePurchased, pending or revoked.
purchaseDatestringWhen it — or this renewal — was bought.
expirationDatestring | nullApple subscriptions only; Google does not report expiry on the device, so ask your server.
isFinishedbooleanApple: finished. Google: acknowledged or consumed.
isAutoRenewingbooleanA subscription set to renew.
quantitynumberUnits bought.
accountTokenstring | nullThe accountToken it was bought with.
verificationDatastringSend this to your server to verify the purchase. Apple: the signed JWS transaction. Google: the purchase token. Shiny.Mobile.InAppPurchases.Server's IPurchaseVerifier takes it as it is.
appIdstringThe bundle id (Apple) or package name (Google).
environmentStoreEnvironmentThe store environment; always Unknown on Google Play.
originalJsonstring | nullThe store's raw JSON for the transaction.
signaturestring | nullGoogle Play: the RSA signature of originalJson. Null on Apple.
revocationDatestring | nullApple: when it was refunded or revoked.
POST/_bridge/purchases/transactions/{transactionId}/finish#

Finishes a purchase once its entitlement is stored. consume makes a consumable buyable again. The bridge finds the purchase in the store by its transaction id, and fails with 404 (purchase_not_found) when the store has no unfinished purchase or entitlement by it — typically because it was finished already.

C#IInAppPurchasesBridge.FinishAsyncTSInAppPurchasesBridge.finish
ParameterInType
transactionIdpathstringrequired
Body
application/json · FinishPurchaseRequest
FinishPurchaseRequest · 1 field
consumebooleanTrue for a consumable, so it can be bought again; false acknowledges a non-consumable or subscription. Apple has no separate consume step, so this only matters on Google Play — but always say what the product is.
Response
204 No content
Errors
404Not found
POST/_bridge/purchases/restore#

Restores purchases and returns the entitlements. On iOS this syncs with the App Store and may ask the user to sign in, so call it only from a "Restore Purchases" button.

C#IInAppPurchasesBridge.RestoreAsyncTSInAppPurchasesBridge.restore
Response
200 Purchase[]
Purchase · 17 fields
platformStorePlatformThe store it was made through.
productIdstringThe store product id.
transactionIdstringUnique per charge — finish it by this. Apple: the transaction id, new on each renewal. Google: the order id, or the purchase token while the purchase is pending.
originalTransactionIdstringStable across renewals. Apple: the original transaction id. Google: the purchase token.
statePurchaseStatePurchased, pending or revoked.
purchaseDatestringWhen it — or this renewal — was bought.
expirationDatestring | nullApple subscriptions only; Google does not report expiry on the device, so ask your server.
isFinishedbooleanApple: finished. Google: acknowledged or consumed.
isAutoRenewingbooleanA subscription set to renew.
quantitynumberUnits bought.
accountTokenstring | nullThe accountToken it was bought with.
verificationDatastringSend this to your server to verify the purchase. Apple: the signed JWS transaction. Google: the purchase token. Shiny.Mobile.InAppPurchases.Server's IPurchaseVerifier takes it as it is.
appIdstringThe bundle id (Apple) or package name (Google).
environmentStoreEnvironmentThe store environment; always Unknown on Google Play.
originalJsonstring | nullThe store's raw JSON for the transaction.
signaturestring | nullGoogle Play: the RSA signature of originalJson. Null on Apple.
revocationDatestring | nullApple: when it was refunded or revoked.
POST/_bridge/purchases/manage#

Opens the store's subscription management screen — on Android, focused on one product when it is named.

C#IInAppPurchasesBridge.ShowManageSubscriptionsAsyncTSInAppPurchasesBridge.showManageSubscriptions
Body
application/json · ManageSubscriptionsRequest
ManageSubscriptionsRequest · 1 field
productIdstring | nullAndroid: focus on this subscription. Apple always opens the full list.
Response
204 No content
EVENTpurchases.updated#

A purchase changed outside a purchase call: an Ask to Buy approval, a pending payment that completed, a renewal, a refund, a purchase on another device, an offer code. The same purchase can arrive more than once, so handle it idempotently by transactionId. Also handed to a purchases.updated handler, so background.js can verify, grant and finish it when no page is open.

C#IInAppPurchasesBridge.OnUpdatedAsyncTSInAppPurchasesBridge.onUpdated
Payload
Purchase
platformStorePlatformThe store it was made through.
productIdstringThe store product id.
transactionIdstringUnique per charge — finish it by this. Apple: the transaction id, new on each renewal. Google: the order id, or the purchase token while the purchase is pending.
originalTransactionIdstringStable across renewals. Apple: the original transaction id. Google: the purchase token.
statePurchaseStatePurchased, pending or revoked.
purchaseDatestringWhen it — or this renewal — was bought.
expirationDatestring | nullApple subscriptions only; Google does not report expiry on the device, so ask your server.
isFinishedbooleanApple: finished. Google: acknowledged or consumed.
isAutoRenewingbooleanA subscription set to renew.
quantitynumberUnits bought.
accountTokenstring | nullThe accountToken it was bought with.
verificationDatastringSend this to your server to verify the purchase. Apple: the signed JWS transaction. Google: the purchase token. Shiny.Mobile.InAppPurchases.Server's IPurchaseVerifier takes it as it is.
appIdstringThe bundle id (Apple) or package name (Google).
environmentStoreEnvironmentThe store environment; always Unknown on Google Play.
originalJsonstring | nullThe store's raw JSON for the transaction.
signaturestring | nullGoogle Play: the RSA signature of originalJson. Null on Apple.
revocationDatestring | nullApple: when it was refunded or revoked.

JSON property names are camelCase and enums travel as their names. A ? marks a property the payload may omit;| null one that may be null.

PurchasesStatusobject#

The store this device buys through, and whether it can buy now.

platformStorePlatformApp Store or Google Play.
canMakePaymentsbooleanFalse when the store is unreachable or payments are blocked — parental controls, MDM, an unsupported country, no Play Store.
StorePlatformenum#

The store a device buys through.

"AppStore""GooglePlay"
ProductsRequestobject#

The products to load.

productIdsstring[]Store product ids, as configured in App Store Connect or the Play Console.
StoreProductobject#

A localized product from the store.

idstringThe store product id.
typeProductTypeOne-time purchase or subscription.
titlestringThe localized name.
descriptionstringThe localized description.
displayPricestringThe localized, formatted price — for a subscription, the recurring price of its default plan.
pricenumberThe price as a number, in currencyCode.
currencyCodestringISO 4217 currency code.
subscriptionOffersSubscriptionOffer[]Google Play: one per base plan and per offer the user is eligible for; buy one with its offerToken. Apple: the introductory offer when the user is eligible, then promotional offers.
subscriptionGroupIdstring | nullApple's subscription group; null on Google Play and for one-time products.
ProductTypeenum#

What kind of product it is.

"OneTime""Subscription"
SubscriptionOfferobject#

A subscription base plan or offer.

offerIdstring | nullApple's offer id or Google's; null for an Apple introductory offer and a Google base plan.
basePlanIdstring | nullGoogle's base plan id; null on Apple.
offerTokenstring | nullGoogle Play: pass it as offerToken to buy this offer. Null on Apple.
isIntroductorybooleanAn Apple introductory offer, or a Google free trial or introductory price.
pricingPhasesPricingPhase[]The price schedule in order — a free trial, then the recurring price.
tagsstring[]Google offer tags; empty on Apple.
PricingPhaseobject#

One phase of a subscription's price schedule.

displayPricestringThe localized, formatted price of one billing period.
pricenumberThe price of one billing period, as a number.
currencyCodestringISO 4217 currency code.
billingPeriodstringAn ISO 8601 duration: P1W, P1M, P1Y.
billingCycleCountnumberHow many periods the phase lasts; 0 recurs until cancelled.
paymentModeSubscriptionPaymentModeHow the phase is charged.
SubscriptionPaymentModeenum#

How a subscription pricing phase is charged.

"Recurring""FreeTrial""PayAsYouGo""PayUpFront"
PurchaseRequestobject#

A product to buy.

productIdstringThe store product id.
accountToken?string | nullYour user's id. It comes back on every transaction and in the stores' server notifications (Apple appAccountToken, Google obfuscatedAccountId), so your server can tell whose purchase it is. Always set it.
offerToken?string | nullGoogle Play: the offerToken to buy; the first base plan when omitted. Ignored on Apple.
quantity?numberApple: how many units of a consumable, 1 to 10. Google lets the user choose in the sheet when the product allows it.
replacement?SubscriptionReplacement | nullGoogle Play: the subscription this one replaces. Ignored on Apple.
SubscriptionReplacementobject#

Replaces a Google Play subscription with another — an upgrade or downgrade. Apple changes plans within a subscription group itself.

oldProductIdstringThe subscription being replaced.
oldPurchaseTokenstringIts purchase token: the old purchase's originalTransactionId.
mode?SubscriptionReplacementModeHow the change is prorated and charged.
SubscriptionReplacementModeenum#

How a Google Play subscription change is prorated and charged.

"Default""WithTimeProration""ChargeProratedPrice""ChargeFullPrice""WithoutProration""Deferred"
PurchaseResultobject#

How a purchase sheet ended.

statusPurchaseResultStatusWhat happened.
purchasePurchase | nullSet on Success, and on Pending when the store reports one.
PurchaseResultStatusenum#

How a purchase sheet ended.

"Success""Pending""Cancelled""AlreadyOwned"
Purchaseobject#

A purchase as the device's store reports it.

platformStorePlatformThe store it was made through.
productIdstringThe store product id.
transactionIdstringUnique per charge — finish it by this. Apple: the transaction id, new on each renewal. Google: the order id, or the purchase token while the purchase is pending.
originalTransactionIdstringStable across renewals. Apple: the original transaction id. Google: the purchase token.
statePurchaseStatePurchased, pending or revoked.
purchaseDatestringWhen it — or this renewal — was bought.
expirationDatestring | nullApple subscriptions only; Google does not report expiry on the device, so ask your server.
isFinishedbooleanApple: finished. Google: acknowledged or consumed.
isAutoRenewingbooleanA subscription set to renew.
quantitynumberUnits bought.
accountTokenstring | nullThe accountToken it was bought with.
verificationDatastringSend this to your server to verify the purchase. Apple: the signed JWS transaction. Google: the purchase token. Shiny.Mobile.InAppPurchases.Server's IPurchaseVerifier takes it as it is.
appIdstringThe bundle id (Apple) or package name (Google).
environmentStoreEnvironmentThe store environment; always Unknown on Google Play.
originalJsonstring | nullThe store's raw JSON for the transaction.
signaturestring | nullGoogle Play: the RSA signature of originalJson. Null on Apple.
revocationDatestring | nullApple: when it was refunded or revoked.
PurchaseStateenum#

Where a purchase stands.

"Purchased""Pending""Revoked"
StoreEnvironmentenum#

Which store environment a purchase was made in.

"Unknown""Production""Sandbox""Xcode"
FinishPurchaseRequestobject#

How to finish a purchase.

consumebooleanTrue for a consumable, so it can be bought again; false acknowledges a non-consumable or subscription. Apple has no separate consume step, so this only matters on Google Play — but always say what the product is.
ManageSubscriptionsRequestobject#

Which subscription management screen to open.

productId?string | nullAndroid: focus on this subscription. Apple always opens the full list.

The purchases routes sit under AppDeviceBridgePolicies.Bridges like every other bridge, so by default only callers on this device can reach them. This bridge doesn’t change the default policy, the host-name check or the launch session. Every purchase still needs the user’s confirmation in the store’s sheet. Verify verificationData on your server before granting anything; never trust the device’s word for it. See Security.