campaignId 1234567890) instead of a real
attribution answer. Mark your own test devices first: Apple ties the
payload to Developer Mode, which is on for every phone you run Xcode
builds on. Left in, pirated users
inflate active-user counts and pull every per-user conversion rate
down by their share.
My apps' analytics SDK asks Apple's AdServices framework, once per install, whether the install came from an Apple Ads campaign. For a sizable share of users, Apple answers with the placeholder payload it documents as test data. In my data that payload marks copies of the app that didn't come from the App Store. The numbers below come from FolioKit, the analytics tool I build and run my apps on, and cover the period from mid-July, when tracking started, to September 23, 2026.
The numbers from two apps
"New users" means people first seen after tracking started. Users who had the app before the SDK shipped and then updated aren't counted as new, and my own test devices are excluded.
| App | New users | Pirated copies | Share | Ever paid |
|---|---|---|---|---|
| LuminaClean (photo cleaner) | 1,026 | 162 | about 16% | 0 of 162 |
| BookBinge (reading tracker) | 123 | 51 | 41% | 0 of 51 |
My other two apps, Inkfall (an arcade game) and TicFlow (a habit and tic tracker), had 3 pirated users each. That's too few to turn into a rate.
The share moves from month to month:
| Month first seen | LuminaClean | BookBinge |
|---|---|---|
| July | 22.6% of 359 | 51.7% of 29 |
| August | 10.1% of 506 | 43.8% of 64 |
| September (to the 23rd) | 18.6% of 161 | 26.7% of 30 |
BookBinge's monthly samples are small (29 to 64 new users), so read its column as rough. LuminaClean's larger months still swing between 10.1% and 22.6%, which matters later: a per-user rate can move by that much with no change in how real users behave.
What pirated users did inside the apps over the last 30 days:
| Last 30 days | LuminaClean | BookBinge |
|---|---|---|
| Share of all events from pirated copies | 10.7% of 29,913 | 17.3% of 1,886 |
| Share of paywall views from pirated copies | 0.0% of 357 | 17.5% of 63 |
The paywall row is the odd one. Pirated LuminaClean users produced a tenth of all events and not one of the 357 paywall views, so the cracked copy apparently has the paywall removed. The cracked BookBinge copy still shows it.
Where the pirated copies are
Country comes from the IP address when events arrive (the address itself isn't stored). The pirated users are far more concentrated than the real ones:
- LuminaClean, pirated copies: Saudi Arabia 63.0% (102 of 162), then the US 5.6%, the UAE 4.9%, Iraq 3.7% and Egypt 3.7%.
- LuminaClean, real users: the largest country is Australia at 8.7%, then Canada at 8.6%.
- BookBinge, pirated copies: Saudi Arabia 39.2% (20 of 51).
I haven't looked into where the copies are shared, and I won't guess. The practical point is what those 102 users do to an unfiltered country chart: they make Saudi Arabia look like one of LuminaClean's biggest markets. If you pick localizations or ad countries from that chart, you'd be choosing for people who don't pay.
How to detect pirated copies with AdServices
The check piggybacks on Apple Ads attribution, which many apps already
run. On first launch the app asks the AdServices framework for a
token (AAAttribution.attributionToken()) and posts it to
Apple's attribution API; the full flow, retries included, is in
Apple Ads attribution
without an MMP. Normally the answer is a real campaign or
"attribution": false. Apple's
AdServices
reference also shows a test payload and says
"AdServices will return a test payload when developer mode is on".
Its identifying fields look like this:
{
"attribution": true,
"orgId": 1234567890,
"campaignId": 1234567890,
"adGroupId": 1234567890,
"keywordId": 123222,
"conversionType": "Download",
"clickDate": "2020-04-08T17:17Z"
}
Those IDs are placeholders, so the check is one comparison after you decode the response:
// data = the body Apple's attribution API returned
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let campaignId = (json?["campaignId"] as? NSNumber)?.int64Value
let isTestPayload = (campaignId == 1234567890)
Store the result on the user record, not as a filter you remember to add to each query. Every metric should leave these users out by default.
Why a cracked copy gets test data: Apple's page on
enabling
Developer Mode says the setting is needed to run development-signed
software, such as an app built from Xcode or an .ipa
installed by hand, and plays no part in App Store or TestFlight
installs. A cracked copy is an .ipa re-signed and
installed outside the App Store. That's my reading of why the signal
works; Apple doesn't describe it as a piracy check.
What the check catches by mistake, and what it misses
- Your own devices. Apple ties the test payload to
the device's Developer Mode setting, not to how the app was installed,
and Developer Mode is on for any phone you run Xcode builds on. On those
phones every build can come back as test data, including TestFlight and
App Store installs; a TestFlight tester without Developer Mode gets
normal answers. Mark your own devices as internal before counting
anything; I mark mine by user ID.
At runtime, StoreKit can tell you as well: Apple's
sandbox
testing guide says apps downloaded from TestFlight always run in
the sandbox environment, and
AppTransactionreports that environment (see the second check below). - Real customers with Developer Mode on. Apple ties the test payload to the device setting, so an iOS developer who bought your app from the App Store could get it too. The AdServices check alone can't separate them from pirated copies.
- Copies installed without Developer Mode. An install method that doesn't need Developer Mode might get a normal answer. Treat the count as a floor.
- Failed calls. The token exchange runs once and needs the network. If it fails, you get no verdict for that user, which is different from "not pirated".
What pirated users do to your numbers
Pirated users are real people using your app. They open it, run sessions and fire events, so they count everywhere an ordinary user counts, and in my data they never paid.
- Active users and usage. In the last 30 days pirated copies produced 10.7% of LuminaClean's events and 17.3% of BookBinge's. Daily and weekly actives, session counts and top screens all include them.
- Every per-user conversion rate. Pirated users sit in the denominator and never in the numerator, so the measured rate is the real rate times the share of real users. With 10% pirated users, a real 5.0% conversion reads as 4.5%. At BookBinge's 41% the gap is far larger.
- Paywall conversion, depending on the crack. LuminaClean's cracked copy never shows the paywall (0.0% of 357 views), so paywall-to-payer is unaffected there. BookBinge's does (17.5% of 63 views), so its unfiltered paywall conversion looks 17.5% worse than it is.
- Funnels. Each step with a per-user denominator mixes two populations. In a LuminaClean funnel that ends at the paywall, part of the drop at the last step is pirated users who never see a paywall, not real users leaving.
- Month-over-month comparisons. LuminaClean's pirated share went from 22.6% in July to 10.1% in August and 18.6% in September. Any unfiltered per-user rate moves with that share.
- Downloads versus new users. A pirated install isn't an App Store download, so it appears in your SDK's new users and not in App Store Connect's downloads. If the two drift apart, pirated copies are one cause to check. More on what App Store Connect counts in App Store Connect analytics in 2026.
This is the same class of problem as internal traffic: a small group that behaves differently from customers and quietly bends every average. If your analytics and RevenueCat already disagree for other reasons, the list of usual causes is in why RevenueCat and your analytics numbers don't match.
What you can do about it
Filter them out of your analytics
This is what I do. The flag lives on the user, every metric excludes flagged users by default, and one switch shows them again. I keep them visible as their own row in the install-source table, so a change in the share shows up without a separate report. Filtering costs nothing, can't hurt a paying customer, and fixes the numbers whether or not you ever block anyone.
Block them, and what that would involve
I don't block pirated copies today. If you want to, this is the work involved:
- A signal you trust. The AdServices test payload also fires for developers with Developer Mode on, so it's a poor reason to lock someone out. A failed App Store signature (below) is a better one. Never block on "couldn't check": a phone that is offline or signed out of the App Store gives you no answer, and that isn't evidence of anything.
- A remote switch. Put the block behind a server-side flag that starts off, so a false positive can be undone in minutes instead of a review cycle.
- An expectation that it gets patched. Whoever removed LuminaClean's paywall from the binary can remove a check too.
- No known upside in revenue. None of the 162 and 51 pirated users paid. I have no data on whether any of them would buy if blocked, so I can't claim blocking earns anything.
A second check: the App Store's signature on the install
StoreKit 2 has a direct signal. Apple describes
AppTransaction
as the customer's purchase of the app, signed by the App Store, and
its guide to
choosing
a receipt validation technique points to AppTransaction
for validating the app download. A copy that didn't come from the App
Store can't produce a verified one.
import StoreKit
func installVerdict() async -> String {
do {
switch try await AppTransaction.shared {
case .verified(let tx):
// production = App Store; sandbox = TestFlight or a development build; xcode = StoreKit testing
return tx.environment == .production ? "app_store" : "test_build"
case .unverified:
return "unverified" // the App Store signature didn't check out
}
} catch {
return "unavailable" // offline or not signed in: unknown, not pirated
}
}
Apple's documentation for
AppTransaction.shared
notes that it throws when the transaction isn't available or the user
isn't signed in to the App Store, and that it may need a network
connection. That's why the catch branch has to mean "unknown".
LuminaClean 3.3 ships this check. It reports the verdict once per launch and blocks nothing. There are no results from it yet, so this post doesn't quote any. The two checks fail in different ways (one depends on Developer Mode, the other on a signature and a network call), so once both have data they can be compared user by user.
FAQ
How can I tell if someone is using a pirated copy of my iOS app?
Two checks work from inside the app: Apple's AdServices attribution
API returns a fixed test payload (IDs of 1234567890) on devices in
Developer Mode, and StoreKit's AppTransaction can't be
verified for a copy that didn't come from the App Store. Mark your own
test devices first.
Do TestFlight and Xcode builds get Apple's AdServices test payload?
It depends on the device, not the build. Apple returns the test payload when Developer Mode is on, which it is on every phone you run Xcode builds on, so builds on those phones can get it, TestFlight included. TestFlight testers without Developer Mode get normal answers. Flag your own devices as internal.
Do users of pirated app copies ever pay?
Not in my apps: 0 of 162 in LuminaClean and 0 of 51 in BookBinge between mid-July and September 23, 2026.
How do pirated copies affect my conversion rate?
Any per-user rate is diluted by the pirated share. At 10% pirated users, a real 5.0% conversion reads as 4.5%. Paywall conversion is affected only if the cracked copy still shows your paywall.
Should I block pirated copies of my app?
Only on a signal you trust (a failed App Store signature, never a failed network call), behind a remote switch, and expecting the check to be patched out. Filtering them out of your analytics comes first and fixes the numbers either way.
Sources
- Apple, attributionToken() (AdServices; attribution payloads and the developer-mode test payload)
- Apple, Enabling Developer Mode on a device
- Apple, AppTransaction, AppTransaction.shared and Choosing a receipt validation technique
- Apple, Testing in-app purchases with sandbox (TestFlight apps run in the sandbox environment)
- My own data: LuminaClean, BookBinge, Inkfall and TicFlow in FolioKit, mid-July to September 23, 2026, test devices excluded