Authors: Matt Giuca <mgiuca@chromium.org>, Glen Robertson <glenrob@chromium.org>
- The problem
- The proposed API
- Full API interface
- Formatting the price
- Security and Privacy Considerations
This document proposes the Digital Goods API for querying and managing digital products to facilitate in-app purchases from web applications. It is complementary to the Payment Request API, which is used to make purchases of the digital products. This API would be linked to a digital store connected to via the user agent.
The problem this API solves is that Payment Request by itself is inadequate for making in-app purchases in existing digital stores, because that API simply asks the user to make a payment of a certain amount (e.g., “Please authorize a transaction of US$3.00”), which is sufficient for websites selling their own products, but established digital distribution services require apps to make purchases by item IDs, not monetary amounts (e.g., “Please authorize the purchase of SHINY_SWORD”); the price being configured per-region on the backend.
The Payment Request API can be used, with a minor modification, to make in-app purchases using the digital distribution service as a payment method, by supplying the desired item IDs as data
in the modifiers
member for that particular payment method. However, there are ancillary operations relating to in-app purchases that are not part of that API:
- Querying the details (e.g., name, description, regional price) of digital items from the store backend.
- Note: Even though the web app developer is ultimately responsible for configuring these items on the server, and could therefore keep track of these without an API, it is important to have a single source of truth. This ensures that the price of items displayed in the app exactly matches the prices that the user will eventually be charged, especially as prices can differ by region, or change at planned times (such as when sale events begin or end).
- Consuming or acknowledging purchases. Digital stores typically do not consider a purchase finalized until the client acknowledges the purchase through a separate API call. This acknowledgment is supposed to be performed once the client “activates” the purchase inside the app.
- Checking the digital items currently owned by the user.
It is typically a requirement for listing an application in a digital store that purchases are made through that store’s billing API. Therefore, access to these operations is a requirement for web sites to be listed in various digital stores, if they wish to sell digital products.
- Listing the available subscription options for your site's service, in the user's currency, as configured with a store backend.
- Check that a user has a purchased resource in your web game, and use it up when appropriate, using the store backend's infrastructure.
- Checking with a store backend that a user is eligible to access premium content on your site, having purchased it or used a promotional code in the past.
The Digital Goods API allows the user agent to provide the above operations, alongside digital store integration via the Payment Request API.
Sites using the proposed API would still need to be configured to work with each individual store they are listed in, but having a standard API means they can potentially have that integration work across multiple browsers. This is similar to how the existing Payment Request API works (sites still need to integrate with each payment provider, e.g., Google Pay, Apple Pay, but their implementation is browser agnostic).
Usage of the API would begin with a call to window.getDigitalGoodsService()
, which might only be available in certain contexts (eg. HTTPS, browser, OS). If available, the method can be called with a service provider URL.The method returns a promise that is rejected if the given service provider is not available:
if (window.getDigitalGoodsService === undefined) {
// Digital Goods API is not supported in this context.
return;
}
try {
const digitalGoodsService = await window.getDigitalGoodsService("https://example.com/billing");
// Use the service here.
...
} catch (error) {
// Our preferred service provider is not available.
// Use a normal web-based payment flow.
return;
}
For backwards compatibility with Digital Goods API v1.0 while both are available, developers should also check whether the returned digitalGoodsService
object is null
:
if (digitalGoodsService === null) {
// Our preferred service provider is not available.
// Use a normal web-based payment flow.
return;
}
The getDetails
method returns server-side details about a given set of items, intended to be displayed to the user in a menu, so that they can see the available purchase options and prices without having to go through a purchase flow.
details = await digitalGoodsService.getDetails(['shiny_sword', 'gem', 'monthly_subscription']);
for (item of details) {
const priceStr = new Intl.NumberFormat(
locale,
{style: 'currency', currency: item.price.currency}
).format(item.price.value);
AddShopMenuItem(item.itemId, item.title, priceStr, item.description);
}
The returned itemDetails
sequence may be in any order and may not include an item if it doesn't exist on the server (i.e. there is not a 1:1 correspondence between the input list and output).
The item ID is a string representing the primary key of the items, configured in the store server. There is no function to get a list of item IDs; those should be hard-coded in the client code or fetched from the developer’s own server.
The item’s price
is a PaymentCurrencyAmount
containing the current price of the item in the user’s current region and currency. It is designed to be formatted for the user’s current locale using Intl.NumberFormat
, as shown above.
The item can optionally have various periods, specified using ISO 8601 durations. The introductory price period can run for multiple such periods, as specified by introductoryPriceCycles
. For further discussion of periods and introductory price cycles, see Issue#20.
The purchase flow itself uses the Payment Request API. We don’t show the full payment request code here, but note that the item ID for any items the user chooses to purchase should be sent in the data
field of a modifiers
entry for the given payment method, in a manner specific to the store. For example:
const details = await digitalGoodsService.getDetails(['monthly_subscription']);
const item = details[0];
new PaymentRequest(
[{supportedMethods: 'https://example.com/billing',
data: {itemId: item.itemId}}]);
Note that as part of this proposal, we are proposing to remove the requirement of the total
member of the details
dictionary, since the source of truth for the item price (that will be displayed to the user in the purchase confirmation dialog) is known by the server, based on the item ID. The exact format of the data
member is up to the store (the spec simply says this is an object
). Some stores may allow multiple items to be purchased at the same time, others only a single item.
The payment response will return a "purchase token" string, which can be used for direct communication between the developer's server and the service provider beyond the Digital Goods API. Such communication can allow the developer to independently verify information about the purchase before granting entitlements. Some stores might require that the developer acknowledge a purchase once it has succeeded, to confirm that it has been recorded.
Purchases that are designed to be purchased multiple times usually need to be marked as "consumed" before they can be purchased again by the user. An example of a consumable purchase is an in-game powerup that makes the player stronger for a short period of time. This can be done with the consume
method:
digitalGoodsService.consume(purchaseToken);
It is preferable to use a direct developer-to-provider API to consume purchases, if one is available, in order to more verifiably ensure that a purchase was used up.
The listPurchases
method allows a client to get a list of items that are currently owned or purchased by the user. This may be necessary to check for entitlements (e.g. whether a subscription, promotional code, or permanent upgrade is active) or to recover from network interruptions during a purchase (e.g. item is purchased but not yet acknowledged). The method returns item IDs and purchase tokens, which should be verified using a direct developer-to-provider API before granting entitlements.
purchases = await digitalGoodsService.listPurchases();
for (p of purchases) {
VerifyAndGrantEntitlement(p.itemId, p.purchaseToken);
}
The listPurchaseHistory
method allows a client to get a list of previous purchases by the user, regardless of current ownership state. Depending on the underlying service provider support, this might be limited to a single purchase record per item.
Expected to be added in Chrome M102+. This is a non-breaking change adding additional methods and optional fields. Use of the new methods/fields will require developers to update supporting code in their apps, such as Android Browser Helper.
- Adds to DigitalGoodsService
Promise<sequence<PurchaseDetails>> listPurchaseHistory();
- Adds to ItemDetails
ItemType type;
sequence<DOMString> iconURLs;
[EnforceRange] unsigned long long introductoryPriceCycles;
- Adds
enum ItemType
[SecureContext]
partial interface Window {
// Rejects the promise if there is no Digital Goods Service associated with
// the given service provider.
Promise<DigitalGoodsService> getDigitalGoodsService(DOMString serviceProvider);
};
[SecureContext]
interface DigitalGoodsService {
Promise<sequence<ItemDetails>> getDetails(sequence<DOMString> itemIds);
Promise<sequence<PurchaseDetails>> listPurchases();
Promise<sequence<PurchaseDetails>> listPurchaseHistory();
Promise<void> consume(DOMString purchaseToken);
};
dictionary ItemDetails {
required DOMString itemId;
required DOMString title;
required PaymentCurrencyAmount price;
ItemType type;
DOMString description;
sequence<DOMString> iconURLs;
// Periods are specified as ISO 8601 durations.
// https://en.wikipedia.org/wiki/ISO_8601#Durations
DOMString subscriptionPeriod;
DOMString freeTrialPeriod;
PaymentCurrencyAmount introductoryPrice;
DOMString introductoryPricePeriod;
[EnforceRange] unsigned long long introductoryPriceCycles;
};
enum ItemType {
"product",
"subscription",
};
dictionary PurchaseDetails {
required DOMString itemId;
required DOMString purchaseToken;
};
In Origin Trial in Chrome M96-M99.
[SecureContext]
partial interface Window {
// Rejects the promise if there is no Digital Goods Service associated with
// the given service provider.
Promise<DigitalGoodsService> getDigitalGoodsService(DOMString serviceProvider);
};
[SecureContext]
interface DigitalGoodsService {
Promise<sequence<ItemDetails>> getDetails(sequence<DOMString> itemIds);
Promise<sequence<PurchaseDetails>> listPurchases();
Promise<void> consume(DOMString purchaseToken);
};
dictionary ItemDetails {
required DOMString itemId;
required DOMString title;
required PaymentCurrencyAmount price;
DOMString description;
// Periods are specified as ISO 8601 durations.
// https://en.wikipedia.org/wiki/ISO_8601#Durations
DOMString subscriptionPeriod;
DOMString freeTrialPeriod;
PaymentCurrencyAmount introductoryPrice;
DOMString introductoryPricePeriod;
};
dictionary PurchaseDetails {
required DOMString itemId;
required DOMString purchaseToken;
};
Origin trial ran in Chrome from M89 to M95 (inclusive).
[SecureContext]
partial interface Window {
// Resolves the promise with null if there is no service associated with the
// given payment method.
Promise<DigitalGoodsService?> getDigitalGoodsService(DOMString paymentMethod);
};
[SecureContext]
interface DigitalGoodsService {
Promise<sequence<ItemDetails>> getDetails(sequence<DOMString> itemIds);
Promise<void> acknowledge(DOMString purchaseToken,
PurchaseType purchaseType);
Promise<sequence<PurchaseDetails>> listPurchases();
};
enum PurchaseType {
"repeatable",
"onetime",
};
dictionary ItemDetails {
required DOMString itemId;
required DOMString title;
required PaymentCurrencyAmount price;
DOMString description;
// Periods are specified as ISO 8601 durations.
// https://en.wikipedia.org/wiki/ISO_8601#Durations
DOMString subscriptionPeriod;
DOMString freeTrialPeriod;
PaymentCurrencyAmount introductoryPrice;
DOMString introductoryPricePeriod;
};
dictionary PurchaseDetails {
required DOMString itemId;
required DOMString purchaseToken;
boolean acknowledged = false;
PurchaseState purchaseState;
// Timestamp in ms since 1970-01-01 00:00 UTC.
DOMTimeStamp purchaseTime;
boolean willAutoRenew = false;
};
enum PurchaseState {
"purchased",
"pending",
};
The ItemDetails struct contains a price
member which gives the price of the item in the user’s currency (this is a PaymentCurrencyAmount, the same format used by the Payment Request and Payment Handler APIs). This is provided purely informationally, so that the price can be displayed to the user before they choose to purchase the item (the price will be re-confirmed to the user in the user-agent-controlled payment dialog). The website does not need to do anything with this price (e.g., pass it into the Payment Request flow), but should show it to the user.
To format the price, use the Intl.NumberFormat API, for example:
new Intl.NumberFormat(
locale,
{style: 'currency',
currency: price.currency}).format(price.value);
This will correctly format the price in the given locale (which should be set to the user’s locale), in the currency that the user will use to make the purchase.
This API should be used in a secure context. Additionally a user agent could restrict use of the API using feature policy and/or restrict it to top-level contexts only. The digital products managed by the API are expected to be specific to one origin, so information retrieved through the API would relate to the current origin only.
This API assumes that the user agent has some existing authentication process for the user, e.g. some extra UI when the API is initialised, or some implicit platform or browsing context. Because an authenticated user is likely needed for the API to be meaningful, and information is only exposed for purchases that user has already made from this origin, there is minimal additional potentially-identifying information to be gained through this API.
- Uses the term “SKU” for items.
- No server-side distinction between “consumable” and “one-time purchase” items. Choice is dynamic: call “consume” to consume an item and make it available for purchase again, call “acknowledge” to acknowledge purchase of a one-time item and not make it available again.
- Uses the term “Item” for items.
- Configure each item in the server UI to be either “consumable” or “one-time purchase”. Call “consume” to consume a consumable item. No acknowledgement required for one-time purchase items.
- Please check our issue tracker.
- Do we need to support pending transactions? (i.e., when your app starts, you’re expected to query pending transactions which were made out-of-app, and acknowledge them).
- In the Play Billing backend, this means you’re supposed to call BillingClient.queryPurchases to get the list of pending unacknowledged transactions.
- See this post for details.
- Added listPurchases.
- Can we combine acknowledge() and consume()? Only reason we can see to not do that is that the Play Billing implementation would not know which method to call, unless we can get it from the SkuDetails, which I don’t see a field for.
- It looks like the Play Store doesn’t distinguish the two on the server. The only way to distinguish this is whether you call acknowledge() or consume().
- Could look at this as a Boolean option on a single method, “make_available_again”.
- How should price be presented through the API? Options:
- As a PaymentCurrencyAmount (a {3-letter currency code, string value} pair). e.g.
"price": {"currency": "USD", "value": "3.50"}
.- Pro: Consistent with Payment Request and Payment Handler APIs (though note that compatibility is not needed in this case).
- Con: Formatting this using Intl.NumberFormat will roundtrip the value through a double, which could result in rounding errors.
- As a currency code and integer amount in micros. e.g.
"priceCurrency": "USD", "priceAmountMicros": 3500000
.- Pro: Directly maps from the Play Billing API.
- Con: Formatting this using Intl.NumberFormat requires dividing by 1000000 (into a double), which could result in rounding errors.
- As an already-formatted string. e.g.
"price": "$3.50"
.- Pro: No formatting / roundtripping needed on the site.
- Pro: Play Billing may have a policy that the string needs to be displayed exactly as given by this API, and this is the only way to guarantee that.
- Con: No access to the value numerically, or the currency code. No way to distinguish, e.g., USD and AUD.
- Con: Difficult to standardize the format of the string (lots of complexity around locale). Hard questions around whether we just pull the price() string straight out of Billing API, or whether the user agent is expected to roundtrip it to conform to a more concrete set of rules.
- Con: No way to localize to the user’s locale, which might be important. For example, formatting EUR in en-UK looks like “€3.50”, while the same currency in de-DE looks like “3,50€”.
- As a PaymentCurrencyAmount (a {3-letter currency code, string value} pair). e.g.