What replaced the receipt, and how do you grandfather paid users with AppTransaction.originalAppVersion?

28.4.5 · L5 · Interview

Predict first

Which original versions count as at least 2.0 by string and by numeric comparison?

import Foundation

func numeric(_ a: String, atLeast b: String) -> Bool { a.compare(b, options: .numeric) != .orderedAscending }
let cutoff = "2.0"
for original in ["1.9", "1.10", "2.0", "2.0.1", "10.0"] {
    print(original, "string compare:", original >= cutoff, "| numeric compare:", numeric(original, atLeast: cutoff))
}
  • string compare gets only 10.0 wrong; numeric compare is correct
  • both comparisons agree for every version
  • numeric compare treats 10.0 as older than 2.0

Mental model. A receipt is a crumpled paper bag full of everything you bought; transactions are individual stamped invoices, and AppTransaction is the proof that you bought the shop key in the first place.

The legacy model was an app receipt (a PKCS#7 blob at appStoreReceiptURL) that the app sent to Apple's verifyReceipt endpoint; that endpoint is deprecated, the format is opaque and parsed by hand, and it mixed every purchase into one blob. StoreKit 2 replaces it with individually signed transactions (JWS) plus RenewalInfo, queried on device and via the Server API and notifications. A separate signed object, AppTransaction (AppTransaction.shared), describes the app download itself: originalAppVersion, originalPurchaseDate, environment, device verification id. Use it to move a paid app to a freemium or subscription model without punishing existing buyers: if the user's original version is below the first version of the new model, unlock for free. Two warnings: in the sandbox/TestFlight the original version is not the real one (Apple documents a placeholder), so test the branch with injected values; and check how the value is defined on each platform (a version string such as 1.10, a build number in the legacy receipt).

The check shows the classic bug when comparing versions: as strings "10.0" >= "2.0" is false, because comparison is lexicographic ("1" sorts before "2"), so version 10.0 wrongly counts as older (the other four happen to agree); compare(options: .numeric) gives the right answers (1.9 and 1.10 older, 2.0, 2.0.1 and 10.0 newer). For migration keep both paths: new buyers use transactions; old receipts are read once on the server (receipt data can still be sent to the server and, for historical purposes, converted via the Server API receipt-to-transaction lookup), then the grandfathering decision is stored against your own user record so it survives.

Lexicographic order puts "10.0" before "2.0".

Common traps

  • Comparing version strings with >=. Use numeric comparison or parse components.
  • Granting grandfathering in code only on the device. Record the decision server side, so reinstalls and other devices agree.

They might follow up with

  • Does the user's original purchase survive a refund? A refund revokes the app purchase; treat revoked AppTransaction data accordingly.
  • What about families? Shared apps can report a different original purchaser; decide the policy up front.

Related questions

  • 28.4.1 How is a StoreKit JWS built, and what must a server check before trusting signedTransactionInfo?
  • 28.4.4 How do you authenticate to the App Store Server API, and what do you use it for?
  • 28.3.2 How do restore, Family Sharing and refunds work in StoreKit 2, and how should the app remove access?
  • 17.4.2 How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must…

← How do you authenticate to the App Store Server API, and what do you…