Attribution
Ad networks and postbacks
Attribute installs from Apple Ads, Google Ads and Meta campaigns that never touched an Appy link, and send installs and events to ad networks or to your own server. Enterprise plan.
What Appy reads from each network
| Network | Platform | Source | Setup |
|---|---|---|---|
| Apple Ads | iOS 14.3+ | The AdServices attribution token, checked with Apple’s attribution API | None. On by default; turn it off in the app’s Ad networks settings. |
| Google Ads | Android | A gclid in the Google Play install referrer | None. |
| Meta (Facebook, Instagram) | Android | The Meta Install Referrer, or the same encrypted data in the Play install referrer | Your Install Referrer Decryption Key in the dashboard, and metaAppId in the Android SDK. |
An Appy link always wins: when a link opened the app or a store referrer carried an Appy click, the install belongs to that link. Ad network data comes next, before any probabilistic match. Network installs are verified and appear with link: null and an attribution.network object.
{
"name": "meta",
"campaignId": "6100000000003",
"campaignName": "Spring installs",
"adGroupId": "6100000000002",
"adGroupName": "Lookalike TR",
"adId": "6100000000001",
"adName": "Story video 15s",
"touch": "view",
"sourceApp": "instagram"
}name is apple_ads, google_ads or meta. Apple Ads adds keywordId, conversionType (Download, Redownload or PreOrder) and country; Google Ads adds clickId (the gclid). For Meta, adGroupId and adGroupName are the ad set and adId and adName the ad. Fields a network does not report are left out. App statistics count these installs in installs.byNetwork.
Apple Ads
On the first launch the iOS SDK reads the attribution token from AdServices and sends it with the launch. Appy asks Apple’s attribution API from its servers. No App Tracking Transparency prompt and no IDFA are involved. Apple’s record can take a few seconds to appear, so Appy retries every 5 seconds up to three times; the result is stored on the install and is not part of onAttribution in the app.
- Apple returns ids, not names: campaign, ad group, keyword and ad. Look the names up in Apple Ads.
- Apple only claims installs from Apple Ads;
attribution: falseleaves the install as it was. - Simulators have no AdServices and send no token. Devices in developer mode get Apple’s test payload, which Appy ignores.
- An Apple Ads result replaces an organic or probabilistic one, never a verified Appy link.
Google Ads
Google Ads app campaigns put a gclid in the Google Play install referrer, which the Android SDK already sends on the first launch. Appy stores it as network.clickId with the referrer’s utm_campaign. Appy does not upload conversions back to Google Ads; keep Firebase or your MMP for that.
Meta
- 1
Copy the decryption key
In the Meta App Dashboard open App settings > Basic, find the Android section and copy the Install Referrer Decryption Key (64 hexadecimal characters). Business Manager admin access is needed.
- 2
Paste it into Appy
Open your app in the Appy dashboard and paste it under Ad networks > Meta, or send it as
metaDecryptionKeywithPATCH /v1/apps/{appId}. Appy stores it encrypted and never returns it; the app showsmetaDecryptionKeySet: true. - 3
Set your Facebook App ID in the SDK
The Android SDK then queries the Facebook, Instagram and Facebook Lite apps on the first launch. Its manifest already declares the
<queries>entries.
Appy.init(this, AppyConfig("appy_pk_...", listOf("acme.appy.to")).apply {
metaAppId = "1234567890"
})Meta reports touch: view for installs after an ad impression and click after a tap. Installs Meta does not claim stay as they were.
Postbacks and webhooks
A postback is an HTTPS URL Appy calls when something happens in your app. Use it to report installs to an ad network that accepts click-id postbacks, or with POST as a webhook to your own server. Up to 10 per app, managed in the dashboard or with the API.
| Trigger | When it fires |
|---|---|
install | Once per new install, after the attribution is final, including the Apple Ads lookup. |
event | For each new in-app or server event, optionally only for the names in eventNames. Duplicates never fire twice. |
curl https://api.appy.to/v1/apps/$APP_ID/postbacks \
-H "Authorization: Bearer $APPY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Network installs",
"trigger": "install",
"url": "https://postback.network.example/install?click_id={param:clickid}&ts={timestamp}",
"requiredParameter": "clickid"
}'Put the network’s click id macro in the query of your Appy link, for example https://acme.appy.to/spring?clickid={CLICK_ID}. Appy keeps the link’s parameters with the install, so {param:clickid} sends it back. requiredParameter makes the postback fire only for installs whose link carried it. The response includes signingSecret once.
Macros
| Macro | Value |
|---|---|
{install_id} {platform} {os_version} {device_model} {app_version} {locale} {user_id} | The install |
{attribution_status} {verified} {link_slug} {utm_source} {utm_medium} {utm_campaign} | Its attribution |
{param:NAME} | Any query parameter of the tapped Appy link |
{network} {campaign_id} {campaign_name} {ad_group_id} {ad_group_name} {ad_id} {keyword_id} | Apple Ads, Google Ads or Meta data |
{event_id} {event_name} {revenue} {currency} | The event, empty on install postbacks |
{timestamp} {timestamp_ms} | When the install or event happened, Unix seconds or milliseconds |
Values are URL-encoded. Macros cannot appear in the host name, and Appy only calls public HTTPS hosts.
Webhook body
{
"type": "event",
"appId": "0f3c9c2e-1b7d-4c55-9e2f-8a6d4b1c2e3f",
"occurredAt": "2026-10-15T10:31:04Z",
"install": {
"installId": "7f0c2b9e-3a61-4c2e-9d0b-5c1e8f2a9b10",
"platform": "ios",
"appVersion": "2.3.1",
"userId": "u_123",
"attribution": {
"status": "attributed",
"verified": true,
"link": null,
"network": {
"name": "apple_ads",
"campaignId": "542370539"
}
}
},
"event": {
"id": "txn_1000000912345",
"name": "purchase",
"revenue": 9.99,
"currency": "USD",
"source": "sdk",
"occurredAt": "2026-10-15T10:31:04Z"
}
}Verify the signature
Every request carries Appy-Signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256 of t, a dot and the raw body (empty for GET), keyed with the postback’s signing secret. Reject requests older than 5 minutes. Appy-Delivery-Id identifies a delivery across retries.
import crypto from 'node:crypto';
export function verifyAppySignature(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
if (parts.v1.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}func VerifyAppySignature(header string, body []byte, secret string) bool {
parts := map[string]string{}
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(part, "=")
parts[key] = value
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(body)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}Delivery and retries
- Any 2xx or 3xx answer counts as delivered. Redirects are not followed.
- Timeouts (10 seconds),
429and5xxare retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. Other4xxanswers fail at once. - The dashboard and
GET /v1/apps/{appId}/postbacks/{postbackId}/deliveriesshow the last 50 deliveries. Logs are kept for 30 days. POST .../testsends one request with sample data and returns what your endpoint answered.
What this does not cover
Appy is not a certified measurement partner of Meta, Google or TikTok and does not feed their campaign optimization. It does not handle SKAdNetwork or AdAttributionKit conversion values, import ad spend or detect ad fraud. Networks that require a device advertising id (IDFA or GAID) in every postback cannot be served, because the SDKs do not read one.