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.

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:

  1. Classify the client: crawler or not, then platform.
  2. Build the store URLs with campaign tags.
  3. Log the click (humans only), and never let logging block the redirect.
  4. Redirect humans with a 302 to the store that matches their platform, if the app exists there.
  5. 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:

  • require of a missing file is a fatal error, which catch (Throwable) does not catch. So the file is checked with is_file first.
  • 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, pinterest or preview match 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. Count other rows whose UA contains Macintosh. Those are either real Macs or iPads that did not send Mobile/.
  • 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, and Cache-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.

  • #deep-links
  • #app-store
  • #google-play
  • #php
  • #flutter
  • #share
  • #analytics
  • #open-graph

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑