One Share Link for iOS and Android, Clicks per Source
A single PHP smart link that sends iPhone users to the App Store and Android users to Google Play, serves preview cards to crawlers, and logs which share button each click came from.
When users share an app from inside the app, the shared text needs a link, and the person receiving it might have an iPhone, an Android phone or a laptop. This post describes the single "smart link" we use for that: one URL per app on our own server. It sends iPhones to the App Store and Android phones to Google Play, shows a two-button page to everyone else, and records which share button each click came from. All of it is one PHP file of under 300 lines on shared hosting, including the app table and the landing-page HTML.
The design below is what the code actually does. We do not quote click numbers here. The point is the mechanism and its edge cases, plus what we would verify before trusting the numbers it produces.
Why one link instead of two store URLs
The first version of most share features looks like this:
Today's ranking is out! #horoscope
https://apps.apple.com/app/id…
https://play.google.com/store/apps/details?id=…
It has three problems:
- Only the first URL becomes a card. X and LINE render a preview for the first link only. The Play link stays as a long bare URL under the card.
- It eats the text budget and looks like spam.
- You cannot count it. Firebase Analytics and similar SDKs only see people who already installed the app. The person who saw the share and tapped the link is exactly who you want to count, and they are invisible to in-app analytics.
So the share text now contains one link to our own domain, with a tag that says which button produced it:
https://example.com/app/<app-id>/?s=card
In the Flutter apps this is a one-line helper, and a unit test asserts that share links never contain apps.apple.com or play.google.com:
static const _base = 'https://example.com/app/<app-id>/';
static String url(String src) => '$_base?s=$src';
The apps put the hashtags before the link. Since the preview card is built from the first URL, putting the link last keeps the tags from being pushed off by the card.
The ?s= source tag
Each share button passes its own source value. Across our apps these are, for example, rank (a ranking screen), card (a single result card), result, saved (from history), score (a game-over screen), board, photo, code and invite.
One is unusual: img. Two apps print the smart link as text onto the shared image (without https://, as a watermark-style footer). A click with s=img means someone typed or copied the URL from a picture they saw. That is a different channel from a tappable link, and it would otherwise be invisible.
On the server, the value is normalized so that it is safe to store and to put into a URL:
$src = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($_GET['s'] ?? '')));
if ($src === '') $src = 'unknown';
The (string) cast handles ?s[]=x. Anything outside [a-z0-9_] is dropped. A missing tag becomes unknown, so it shows up in the counts instead of disappearing.
Request flow
Each app has a two-line index.php in its own folder that sets the app ID and includes the shared file. Everything else is in that one file. The app table holds, per app: the App Store numeric ID (or null), the Play package name (or null), the landing-page language, a name, a description and an OG image. Right now it defines 10 apps: 6 on both stores and 4 on Android only. One of the four is waiting for its iOS release, and its ID will be filled in later.
An unknown app ID returns a plain-text 404. That has an operational consequence: the server entry has to be deployed before the app version that shares the link is released. One of our apps keeps a TODO about exactly that next to its share-URL constant.
The flow for a request:
- Classify the client: crawler or not, then platform.
- Build the store URLs with campaign tags.
- Log the click (humans only), and never let logging block the redirect.
- Redirect humans with a 302 to the store that matches their platform, if the app exists there.
- Everyone else gets the landing page: crawlers, desktops, unknown devices, and iPhones opening an Android-only app.
Crawlers get HTML, not a redirect
function isCrawler(string $ua): bool {
if ($ua === '') return true; // no UA: not treated as a person
return (bool)preg_match('~(bot|crawler|spider|preview|facebookexternalhit|twitterbot|slackbot|'
. 'discordbot|whatsapp|telegrambot|line\-?spider|linebot|skypeuripreview|embedly|'
. 'quora link preview|pinterest|redditbot|applebot|vkshare|googlebot|bingbot|yeti|daum|'
. 'petalbot|headlesschrome)~i', $ua);
}
If a preview crawler gets a 302, the card is built from the store page: the store's title and image, not yours. So crawlers get the landing page with og:title, og:description, og:image and twitter:card filled in from the app table. They are not redirected and not logged, so the counts are not inflated by every preview fetch.
Platform detection
function platform(string $ua): string {
if (preg_match('~(iphone|ipad|ipod)~i', $ua)) return 'ios';
if (preg_match('~macintosh~i', $ua) && preg_match('~mobile/~i', $ua)) return 'ios'; // iPad posing as a Mac
if (preg_match('~android~i', $ua)) return 'android';
return 'other';
}
Since iPadOS 13, iPads can identify themselves as a Mac. The second line catches the case where the UA still has a Mobile/ token. Anything that falls through gets the landing page, which still works. It only costs one extra tap.
Store URLs with campaign tags
$play = 'https://play.google.com/store/apps/details?id=' . rawurlencode($pkg)
. '&referrer=' . rawurlencode('utm_source=sns_share&utm_medium=app&utm_campaign=' . $src);
$ios = 'https://apps.apple.com/app/id' . rawurlencode($iosId)
. '?ct=' . rawurlencode('sns_share_' . $src) . '&mt=8';
Play passes the referrer through to the install referrer. On the App Store, the ct campaign token may be ignored unless the link also carries a provider token (pt). The code's comments say so explicitly, and that is why our own click log is the number we actually use. Store-side attribution is a bonus.
Logging that cannot break the redirect
if (!$isCrawler) {
try {
if (!is_file($dbFile)) throw new RuntimeException('db config missing');
require_once $dbFile;
// insert: app, src, platform, referer (≤255), UA (≤255), hashed IP, UTC timestamp
} catch (Throwable $e) {
// ignore: losing a count is better than losing the visitor
}
}
Two details matter here:
requireof a missing file is a fatal error, whichcatch (Throwable)does not catch. So the file is checked withis_filefirst.- The insert uses a prepared statement, and every failure is swallowed. The redirect always happens.
The raw IP is not stored. The code takes the first address from the CDN's client-IP header, X-Forwarded-For or REMOTE_ADDR, and stores an MD5 of it with a fixed salt. That is enough to count roughly unique visitors per source.
The landing page must never be cached
Everyone shares the same URL, but the response depends on the User-Agent. If a shared cache stored the HTML that a crawler received, the next iPhone user would see the landing page instead of going to the store. A note in the code records that on our host, LiteSpeed ignored Vary: User-Agent and keyed its cache on the URL alone (tested 2026-11-03). So the page sends:
header('Vary: User-Agent, Accept-Encoding');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
The redirects are 302, not 301. A 301 is cacheable by default, and we had already seen our host's CDN keep serving cached 301s.
Edge cases, as the code handles them
| Visitor | What happens | Logged? |
|---|---|---|
| iPhone, app on App Store | 302 to App Store with ct |
Yes, ios |
| Android phone | 302 to Play with referrer |
Yes, android |
| iPhone, Android-only app | Landing page with only the Play button (no dead App Store link) | Yes, ios |
| Desktop browser | Landing page with both buttons | Yes, other |
| Preview crawler (X, LINE, Slack, …) | Landing page with OG tags, no redirect | No |
| Empty User-Agent | Treated as a crawler: landing page | No |
| Unknown app ID | Plain 404 | No |
| Database down or config missing | Redirect still happens | No (silently) |
There is no special handling for in-app browsers. The LINE, Instagram or X in-app browser on an iPhone sends an iPhone UA, so it gets the 302 to the App Store like Safari does. Whether a particular in-app browser then opens the App Store app or shows a web page is up to that browser.
What we would verify before trusting the numbers
The code is simple, and the gaps are in what it assumes. If we were writing a report on these clicks, we would check these first:
- Crawler regex false positives. Short tokens like
daum,pinterestorpreviewmatch anywhere in the UA. If an app's in-app browser adds its own name to the UA, real people from that app are treated as crawlers: they see the landing page and are not counted. Group the logged UAs and look for gaps by app. - iPads that fall through to
other. Countotherrows whose UA containsMacintosh. Those are either real Macs or iPads that did not sendMobile/. - Which IP header your CDN sets. The code trusts the CDN's client-IP header first, then
X-Forwarded-For. A header that your CDN does not set can be supplied by the client. For rough unique counts that is acceptable, but it is not proof of anything. - The hash is pseudonymous, not anonymous. An MD5 of an IPv4 address with a fixed salt can be reversed by trying all 2^32 addresses. If you need stronger privacy, use an HMAC with a secret that rotates daily, which still lets you deduplicate within a day.
- Whether the 302 itself is cached — we checked (2026-11-09). The code sets its no-store headers only on the landing page, and the redirect branch exits before them, so we fetched one unique test URL as iPhone, then Android, then iPhone again. All three came back with the right store,
x-hcdn-cache-status: MISS, andCache-Control: no-cache, no-store, must-revalidate, max-age=0. The server stack added that header to the PHP response even though our code never sent it. So the 302 is not cached here. We still plan to set the header explicitly in the redirect branch, so the code doesn't depend on a host default.
A query for "clicks per source per day, roughly unique":
SELECT DATE(clicked_at) AS day, src, platform,
COUNT(*) AS clicks, COUNT(DISTINCT ip_hash) AS uniq
FROM clicks
WHERE app_id = ? AND clicked_at >= UTC_TIMESTAMP() - INTERVAL 30 DAY
GROUP BY day, src, platform
ORDER BY day DESC, clicks DESC;
The comparison that is useful in practice: rank against card for the same app, or img against the text-link sources. These show which share button actually brings people. That was the reason for building the link in the first place.
FAQ
Why not put both App Store and Google Play links in the share text?
X and LINE turn only the first URL into a preview card, so the second store link stays as a long bare URL, which uses up characters and looks like spam. You also cannot count clicks from people who do not have the app yet. One link to your own server solves both.
Should a smart link redirect crawlers like Twitterbot to the store?
No. If the preview crawler gets a 302, the card shows the store page instead of your text and image. Serve crawlers an HTML page with Open Graph tags, and redirect only human visitors.
Can Vary: User-Agent stop a cache from mixing up the responses?
Not on the LiteSpeed setup we tested, which keyed its cache on the URL only. Because every visitor uses the same URL but gets a different response, the landing page sends Cache-Control: no-store, and redirects use 302, not 301.