Apple Ads
Credit App Store installs to the Apple Ads campaign, ad group and keyword that won them, and see ROAS and payback per keyword.
Why installs need this
Ripples files every person under a channel from how they arrived: the referrer, UTM tags and ad click IDs on their first visit. An App Store install carries none of those. When your app opens its first screen (a native view or a webview running the Ripples script), that first touch is Direct, and every Apple Ads install is counted as Direct with it.
Apple knows better. Through the AdServices framework your app can ask Apple, without the ATT prompt and without the IDFA, which campaign, ad group and keyword won the install. Sending that answer to Ripples makes it the person’s first touch:
- they appear under the Apple Ads channel in Pulse, Channels, Cohorts, Revenue and Marketing;
- their signups, payments and retention count toward that channel and campaign;
- with the Apple Ads account connected, spend lands under the same channel, so ROAS, CAC and payback work, down to the keyword.
1. Send Apple’s answer
Apple gives the device a short-lived attribution token (AAAttribution.attributionToken(), iOS 14.3+). Exchange it for the attribution one of two ways.
Your backend resolves the token
Your app sends the token to your server; your server POSTs it to Apple and forwards Apple’s JSON to Ripples. Use this if you already have a backend. It needs your secret key, because a resolved answer could otherwise be made up.
curl -X POST https://api.ripples.sh/v1/ingest/attribution \
-H "Authorization: Bearer priv_XXXX" \
-H "Content-Type: application/json" \
-d '{
"user_id": "4812",
"visitor_id": "0b8f6c1e-5d2a-4c1b-9d7e-3f0a2b4c6d8e",
"resolved_at": "2026-10-02T10:04:11Z",
"attribution": {
"provider": "apple_ads",
"attribution": true,
"orgId": 40669820,
"campaignId": 542370539,
"adGroupId": 542317095,
"keywordId": 87675432,
"adId": 542317136,
"countryOrRegion": "CA",
"conversionType": "Download",
"claimType": "Click"
}
}'
- Paste Apple’s response body into
attributionas it is, plus"provider": "apple_ads". The snake_case names (campaign_id,country_or_region, …) work too. - Send
attribution: falseanswers as well. They are recorded and counted; nothing else happens. visitor_idis the Ripples visitor ID of the device,window.ripples.getVisitorId()in a webview once the script has loaded. Send it whenever you have it: it is how the install’s own first screens get credited too.user_idis the id youridentify()sends. Either works, both is best.resolved_atis when your server got Apple’s answer. It keeps a replay or a backfill from being dated today.- The response is
202 {"ok": true, "id": 812, "duplicate": false, "status": "received"}. Sending the same answer for the same person again returnsduplicate: trueand changes nothing.
Ripples resolves the token
No backend? Send the token itself and Ripples asks Apple, retrying the way Apple recommends until the token expires (24 hours). A publishable key is fine here: Apple verifies the token, so it cannot be forged.
From a webview with the Ripples script (v1.5.0 or later):
window.ripples.attribution({ apple_ads_token: token })
Or from anywhere else:
curl -X POST https://api.ripples.sh/v1/ingest/attribution \
-H "Authorization: Bearer <project-token>" \
-H "Content-Type: application/json" \
-d '{"visitor_id": "0b8f6c1e-...", "attribution": {"provider": "apple_ads", "token": "<token>"}}'
Server SDKs that queue events can send the same object through /v1/ingest/batch as {"$type": "attribution", ...}.
Capacitor: getting the token
A webview cannot call AdServices, so add a small native plugin:
import Capacitor
import AdServices
@objc(AppleAdsAttributionPlugin)
public class AppleAdsAttributionPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AppleAdsAttributionPlugin"
public let jsName = "AppleAdsAttribution"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "token", returnType: CAPPluginReturnPromise)
]
@objc func token(_ call: CAPPluginCall) {
guard #available(iOS 14.3, *) else { return call.resolve([:]) }
do {
call.resolve(["token": try AAAttribution.attributionToken()])
} catch {
call.reject(error.localizedDescription)
}
}
}
import { registerPlugin } from '@capacitor/core'
const AppleAdsAttribution = registerPlugin('AppleAdsAttribution')
// Once per install, once the Ripples script (v1.5.0+) has loaded: the
// loading stub has no attribution(). localStorage is wiped when the app is
// deleted, so a reinstall sends again.
if (!localStorage.getItem('rpl_asa_sent') && typeof window.ripples?.attribution === 'function') {
const { token } = await AppleAdsAttribution.token()
if (token) {
window.ripples.attribution({ apple_ads_token: token })
localStorage.setItem('rpl_asa_sent', '1')
}
}
Mark the install sent only after the loaded script has taken the token. The page never hears what Ripples did with it, so a flag written any earlier can hide an install that was never sent. If the script is not there yet, try again next launch with a fresh token rather than an old one: tokens expire after 24 hours.
How the install becomes the first touch
| The person’s current first touch | What happens |
|---|---|
| None yet, or Direct | Apple Ads becomes the first touch |
| A real source (search, social, an ad, a referral, email) that came earlier | Kept. Apple also claims the install; the inspector shows both |
| First seen more than 30 days before the install (90 for pre-orders) | Kept: a returning user reinstalling, not an acquisition |
| Already credited to an earlier Apple Ads install | Kept: the reinstall still counts in Apple’s own numbers |
Once applied, a later Direct visit on any of the person’s devices cannot displace it. An earlier real touch still can, as with any first touch.
Apple Ads installs are filed under the stock Apple Ads channel. If you customised your channels, add a rule for utm_source matching apple_ads (Settings → Channels), or they land in Paid (Other); the Apple Ads status tool flags the mismatch.
2. Connect Apple Ads for spend
Settings → Integrations → Apple Ads. Ripples generates a key pair and walks you through it:
- In Apple Ads, Account Settings → User Management → Invite Users. Give the API user API Account Read Only, or API Account Manager if you want your AI assistant to pause campaigns, change bids and add keywords through Ripples.
- Signed in as that user, Account Settings → API: paste the public key Ripples shows, save, and copy the
clientId,teamIdandkeyIdback into Ripples. - Choose the ad account and the app.
Ripples imports the last 90 days of spend (Apple’s limit for daily reports) and re-syncs every night, by campaign and country for ROAS, and by ad group, keyword and search term for the detail below. The private key stays encrypted on Ripples’ servers and never reaches your browser.
Reading the results
- Channel level: the Apple Ads row in Pulse, Channels and Cohorts, with spend, CAC, ROAS and payback like any paid channel.
- Per campaign, ad group or keyword: ask your assistant, or call
ripples_apple_adswithaction: "performance"andlevel: "keyword". Each row has spend, Apple’s installs, the new people Ripples credits, CPI, CAC, D0 / D7 / D30 revenue and ROAS, and the payback day. Windows that have not closed yet are empty, never zero. - Health:
ripples_apple_adsaction: "status"shows whether spend is current, how many installs were attributed in the last week, and what share of Apple’s reported installs that is. A share that drops is usually an app build that stopped sending the token.
Installs won by Search Match have no keyword; they show as a “(no keyword)” row. Search terms carry spend but no people, because Apple attributes installs to keywords, not to what was typed.
Managing campaigns through your assistant
With Allow campaign changes from MCP on (Settings → Integrations → Apple Ads) and the API user on API Account Manager, your assistant can change the account as well as read it. Every change is a dry run first: Ripples reads what Apple holds, checks the change and shows what would happen, including the most it could spend. Nothing is applied until you confirm, and every change is logged on the settings page.
- Read before you change.
ripples_apple_ads_liveshows each campaign’s and ad group’s dates and why Apple is not serving it (for exampleSCHEDULE_PENDINGwith the start date).action: "tree"returns a whole campaign: ad groups, targeting, keywords with bids, negatives and ads.action: "lint"finds structural mistakes, such as Search Match left on in an exact-match group, an ad group that starts after its campaign, or a negative that blocks one of your own keywords, each with the change that fixes it. - Schedule and targeting. Change a campaign’s or ad group’s start and end, switch Search Match, and set device, customer type, age, gender, location and hour targeting. “Pause after 48 hours” is an end time: Apple stops the campaign itself, so nothing depends on Ripples being up.
- Build. Create an ad group, clone an ad group or a whole campaign into other countries with adjusted bids, or create a campaign. Everything is created paused, only what is missing is created, and a build that fails part way can be run again to finish. The dry run lists what will exist and what lint thinks of it before anything is created.
- Spend guard. Ask Ripples to pause a campaign once it has spent a set amount since it started, or over the last 7 or 30 days. Apple has no lifetime budget for this. Ripples checks every 15 minutes, and because Apple reports today’s spend a few hours late, the dry run tells you how far over the cap a campaign can go before it is paused. Turning changes from MCP off cancels every guard.
Age or gender targeting has a cost: Apple does not attribute installs from those ad groups, so Ripples counts them as organic. The status check and lint both flag it.
Testing
With Developer Mode on (Settings → Privacy & Security → Developer Mode), Apple returns a fixed sample instead of a real answer. Ripples records it with the status test and never applies it, so your own test builds cannot credit a campaign. An install from the App Store with Developer Mode off is real data.
FAQ
Do I need the ATT prompt? No. AdServices works without it. When Apple leaves out the tap time, Ripples dates the install by the app’s first screen.
What about SKAdNetwork / AdAttributionKit? They report aggregates with no per-person id, so there is nothing to join to a person. AdServices is per install.
Android? Google Play’s Install Referrer has the same shape and will use the same endpoint with its own provider.