PLATFORM COMPLIANCE
Reporting External Purchases to Apple
When you sell through Xsolla on storefronts where Apple permits external purchases, Apple still requires you to report those transactions. This page explains what Apple expects, and how to build that report from the data Xsolla already sends you.
Who reports what
Reporting is part of your integration, not something Xsolla does on your behalf.
| Handled by | |
|---|---|
| Processing the payment, tax calculation, refunds | Xsolla |
| Notifying your backend of each order | Xsolla (webhooks) |
| Obtaining Apple's external purchase token in your app | You |
| Filing the report with Apple | You, from your own server |
Apple's reporting API is authenticated with your App Store Connect key and is tied to your app record, so no payment provider can file on your behalf. What Xsolla provides is the transaction data the report is built from.
When this applies
Apple's rules differ sharply by storefront, so check where you actually ship before building anything.
| Storefront | Entitlement | Apple commission | Reporting required |
|---|---|---|---|
| United States | Not required | Not currently charged | No |
| Japan | Required | Yes | Yes |
| Brazil | Required | Yes | Yes |
| European Union | Required | Yes | Yes |
Rates, deadlines and eligibility change often, and they differ per region and per developer program. This page deliberately avoids repeating Apple's numbers. Always check Apple's own pages for current terms: Payment options in Japan ↗, Payment options in Brazil ↗, and Payment options in the EU ↗.
Apple's EU terms change on October 1, 2026, including which payment options you may combine in one app. If you ship in the EU, re-read Apple's page before you settle on a design.
The entitlement is requested from and granted by Apple, and comes with its own addendum to your developer agreement. Sort that out before you write code, since the StoreKit APIs below return nothing without it.
To enable external payments only for the storefronts that permit them, see How to detect iOS Storefront?.
The flow end to end
- Your app checks eligibility and asks StoreKit for an external purchase token.
- Your app sends that token to your backend, which stores it against the customer.
- The player buys through Xsolla as normal.
- Xsolla sends your backend an
order_paidwebhook with the financial detail of that order. - Your backend pairs the token with the order data and files the report with Apple.
- Refunds and chargebacks arrive as
order_canceledand are reported to Apple as refund line items.
Getting the token
Tokens come from StoreKit, not from the Xsolla SDK. Apple's ExternalPurchaseCustomLink API is the current surface for custom links and in-app alternative payments. Apple requires these calls in a fixed order: canMakePayments, then isEligible, then the disclosure sheet.
ExternalPurchaseCustomLink is a Swift enumeration with async members, so it has no Objective-C counterpart. This differs from the SKPaymentQueue-style API that Xsolla SDK mirrors, which is available in both languages. If your app is written in Objective-C, add a small Swift file to your target and expose it with @objc, as shown in the Objective-C tab below.
- Swift
- Objective-C
// Whether this player is allowed to pay at all. Must come first.
guard AppStore.canMakePayments else { return }
// False when the app lacks the entitlement, or the storefront doesn't permit external purchases.
guard await ExternalPurchaseCustomLink.isEligible else { return }
// Token type depends on your region and flow. Apple defines several,
// including in-app alternative payment and link-out variants.
guard let token = try await ExternalPurchaseCustomLink.token(for: "LINK_OUT") else { return }
// Send token.value to your own backend and store it before continuing.
await sendTokenToYourServer(token.value)
// Required. Must follow a deliberate action such as a button tap.
// Use .browser when leaving the app, .withinApp for a web view or native flow inside it.
let notice = try await ExternalPurchaseCustomLink.showNotice(type: .browser)
guard case .continued = notice else { return }
// ...then start your Xsolla purchase or link-out.
Add this Swift file to your app target:
import StoreKit
@objc(XSExternalPurchaseBridge)
public final class ExternalPurchaseBridge: NSObject {
/// Returns the token string, or nil when the app isn't eligible or no active token exists.
@objc(requestTokenOfType:completion:)
public static func requestToken(ofType tokenType: String,
completion: @escaping (String?, Error?) -> Void) {
Task { @MainActor in
do {
guard SKPaymentQueue.canMakePayments(),
await ExternalPurchaseCustomLink.isEligible else {
completion(nil, nil)
return
}
let token = try await ExternalPurchaseCustomLink.token(for: tokenType)
completion(token?.value, nil)
} catch {
completion(nil, error)
}
}
}
/// Shows Apple's disclosure sheet. Continue only when `shouldContinue` is YES.
@objc(showNoticeForBrowserWithCompletion:)
public static func showNoticeForBrowser(completion: @escaping (Bool, Error?) -> Void) {
Task { @MainActor in
do {
let result = try await ExternalPurchaseCustomLink.showNotice(type: .browser)
switch result {
case .continued: completion(true, nil)
default: completion(false, nil)
}
} catch {
completion(false, error)
}
}
}
}
Then call it from Objective-C:
#import "YourApp-Swift.h"
[XSExternalPurchaseBridge requestTokenOfType:@"LINK_OUT"
completion:^(NSString *token, NSError *error) {
if (token == nil) { return; }
// Send the token to your own backend and store it before continuing.
[self sendTokenToYourServer:token];
[XSExternalPurchaseBridge showNoticeForBrowserWithCompletion:^(BOOL shouldContinue, NSError *e) {
if (!shouldContinue) { return; }
// ...then start your Xsolla purchase or link-out.
}];
}];
The token's value is a Base64URL-encoded JSON string. Decode it to read externalPurchaseId, which is the field Apple's report is keyed on. Send that to your backend and keep it.
Which token types apply, when to request them, and how long they stay valid all vary by region. See Apple's Receiving and decoding external purchase tokens ↗ for the current rules.
In Japan, Brazil and the EU, Apple additionally requires an in-app disclosure sheet telling players they are transacting with you rather than with Apple, before you route them to an external purchase. It is system-provided, so showNotice is all you call.
Players can choose not to see it again, so branch on the result the call returns rather than on whether anything appeared. A silent call is not a failure.
Apple publishes the required wording and downloadable assets in the "In-app disclosure sheet" section of Payment options in Japan ↗, Payment options in Brazil ↗ and Payment options in the EU ↗.
Building the report from webhook data
Apple's report covers one token and the transactions attached to it. Most of what a line item needs is already in the order_paid payload, under billing.payment_details.
The mapping below is for one-time purchases, which is what Xsolla SDK sells. Apple reports subscriptions as a separate line item type with its own fields, including renewals and zero-charge events such as free-trial starts. If you sell subscriptions through a Web Shop link-out, don't reuse this mapping for them — see Apple's reporting guide ↗ for the subscription line item.
| Apple line item field | Where it comes from |
|---|---|
externalPurchaseId | Decoded from the Apple token you stored |
lineItemId | You generate it. Persist it, refunds reference it |
creationDate | billing.transaction.payment_date, converted to epoch milliseconds |
eventType | order_paid is a purchase, order_canceled is a refund |
productType | items[].type, or the presence of billing.purchase.subscription |
productIdentifier | items[].sku |
quantity | items[].quantity |
amountTaxInclusive | billing.payment_details.payment.amount, converted to minor units |
taxAmount | billing.payment_details.vat.amount |
amountTaxExclusive | Tax-inclusive amount minus the tax amount |
netAmountTaxExclusive | Running net for the line item across purchases and refunds |
reportingCurrency | billing.payment_details.payment.currency |
pricingCurrency | order.currency |
taxCountry | user.country, converted to a three-letter code |
referenceLineItemId | On refunds, the lineItemId of the original purchase |
billing.payment_details.payout is your share after Xsolla's fees. Apple's report expects the amount the player was charged, which is billing.payment_details.payment. Using payout under-reports your transactions and produces an incorrect commission calculation.
billing.payment_details.vat is the general consumption-tax field despite its name, not a EU-only one. It carries JCT in Japan and the local tax in Brazil just as it carries VAT in the EU, so it is the field to use for taxAmount in every market where Apple requires reporting.
user.country is the jurisdiction the tax was paid in, which is what Apple's taxCountry asks for. The only conversion needed is the format: user.country is a two-letter ISO 3166-1 alpha-2 code, while Apple expects the three-letter alpha-3 form, such as ES becoming ESP.
The fields above are the subset the Apple report needs. The payload carries considerably more — subscription and gift objects, promotions, coupons, fee breakdowns — and the same webhook drives your fulfillment. For the full structure, delivery guarantees and signature verification, see the webhooks documentation.
Amounts and rounding
Webhook amounts are decimal values, while Apple expects integer minor units. Convert with a decimal type rather than a float, and derive the tax-exclusive amount by subtracting the tax you reported rather than recomputing it from a percentage, so the three figures always reconcile.
Refunds
order_canceled covers both refunds and chargebacks. Report both as refund line items, referencing the lineItemId of the original purchase. Deduplicate on order.id, since webhooks are retried and a repeated delivery must not produce a second refund line item.
What Xsolla can't tell you
Some of Apple's requirements sit outside anything Xsolla observes. Your backend has to cover them:
- Tokens that produced no purchase. Apple expects these reported too. There's no order and therefore no webhook, so your token store is the only record.
- Tokens Apple knows about that you don't. Apple can notify you of a token your system has never seen, which has its own reporting status.
- Line item identity.
lineItemIdvalues are yours to generate and persist. Without them, refunds can't reference the purchase they reverse. - Running totals. The net amount on a line item accumulates across purchases and refunds, so it can't be derived from a single webhook in isolation.
Testing
The External Purchase APIs return environment-specific tokens. A token created in the sandbox has an externalPurchaseId beginning with SANDBOX, so your backend can route it away from production reporting. Xsolla test orders are marked separately, with order.mode: "sandbox" on the webhook.
Further reading
- External Purchase Server API ↗ — the reporting endpoints and line item schema
- Receiving and decoding external purchase tokens ↗
- Reporting tokens and transactions ↗
- Xsolla webhooks — payload reference and signature verification
- How to detect iOS Storefront?