Deep Linking example: Sainsbury’s SmartShop

Recently the local Sainsbury’s in my area has now required users of the SmartShop mobile app to scan a QR code, even if they already have the app. This was not originally a requirement. I was intrigued as to why this had changed and forced you to scan a QR code for their app to even allow you to start a shopping session to begin with. I decided to take a picture of the QR code with my phone camera to decode and review later on.

Decoding a QR code

The first step, using zbarcam to scan the QR code from the picture I took on my phone, so the data behind it can be revealed. I couldn’t use zbarimg as the picture itself on my phone would have a lot of useless data for parsing QR code data, so that would fail.

Using zbarcam to scan the Sainsbury’s SmartShop QR code from my Android device.

Using zbarcam reveals the data behind the QR code:

QR-Code:https://go.onelink.me/2838942512/4fd5c0b1?af_qr=true

No surprise, it’s URL data, one of the most common uses of QR codes. This is actually an example of Deep Linking. The domain onelink.me relates to a company called AppsFlyer and they are used by a lot of major brands for performing this kind of process. Essentially linking actions that might start on a device via a web browser to then transition to a mobile app, while providing tracking an analytics to join up the journey and touch points. In short, marketing analytics and better conversion tracking, most commonly seen with E-commerce.

What we have here is a URL, but it’s actually a “smart URL”. Behind it, there are a few important processes going on. We have a couple of parts in the URL which are likely related to the analytics tracking behind it.

The first part in the URL 2838942512 looks to be an app ID within the AppsFlyer service. I know this as sending a deliberately bogus URL with a fake string of numbers like “0000000000”, the request responds with a 404 and “app_not_found”. Interestingly if you drop the “4fd5c0b1” part of the URL, the request still works (more on that later).

The URL parameter of af_qr is likely used to determine if the URL was used by scanning a QR code, for analytics purposes this query parameter can likely be used as tracking dimension. The prefix of af relates to the AppsFlyer service.

Using cURL, I’ll make a request so I can see the headers and what is happening in verbose mode.

curl -v https://go.onelink.me/2838942512/4fd5c0b1?af_qr=true
* Trying 54.192.137.42:443...
* Connected to go.onelink.me (54.192.137.42) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=*.onelink.me
* start date: Sep 3 00:00:00 2020 GMT
* expire date: Oct 3 12:00:00 2021 GMT
* subjectAltName: host "go.onelink.me" matched cert's "*.onelink.me"
* issuer: C=US; O=Amazon; OU=Server CA 1B; CN=Amazon
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x564d224a3560)
> GET /2838942512/4fd5c0b1?af_qr=true HTTP/2
> Host: go.onelink.me
> user-agent: curl/7.74.0
> accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 128)!
< HTTP/2 302
< content-type: application/octet-stream
< content-length: 0
< location: https://apps.apple.com/GB/app/id976551005?mt=8
< date: Sun, 28 Mar 2021 14:45:03 GMT
< server: http-kit
< strict-transport-security: max-age=31536000; includeSubDomains
< x-cache: Miss from cloudfront
< via: 1.1 e8e9550625d3e8f605abc4417e820fc0.cloudfront.net (CloudFront)
< x-amz-cf-pop: LHR62-C5
< x-amz-cf-id: DAnjkyaD3cSOEf_Vya3fnB9YWd2qQJ93T3VDTnk3eHUO6hOyeax85w==
<
* Connection #0 to host go.onelink.me left intact

Most of the verbose information isn’t really useful, AppsFlyer use Amazon Web Services in their infrastructure, cool but really we just need to see that the location following this URL is:

https://apps.apple.com/GB/app/id976551005?mt=8

However, this isn’t always going to be the location, because this URL is smart remember! The system behind this URL is actually looking at the User-Agent in the request and then determining what the destination URL should be. In my first example, you can see the User-Agent is the default: curl/7.74.0, this however is very generic and won’t tell the AppsFlyer Analytics system anything useful about my device. Granted, they won’t on average expect this User-Agent to be appearing much, so it would appear the default action/routing rules for this URL is to go to the Apple App Store if not matched by any other rule. We can confirm this theory by faking the User-Agent to something more expected, like an Android device.

curl -v -H "User-Agent: Mozilla/5.0 (Linux; Android 7.1.2; AFTMM Build/NS6265; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/70.0.3538.110 Mobile Safari/537.36" https://go.onelink.me/2838942512/4fd5c0b1?af_qr=true

Here, we’ve used cURL again, but set a specific User-Agent value to be sent in the request, this is mimicking an Android device. Sending this request we can see that it now changes the final destination in the location header:

curl -v -H "User-Agent: Mozilla/5.0 (Linux; Android 7.1.2; AFTMM Build/NS6265; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/70.0.3538.110 Mobile Safari/537.36" https://go.onelink.me/2838942512/4fd5c0b1?af_qr=true
* Trying 143.204.180.31:443...
* Connected to go.onelink.me (143.204.180.31) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=*.onelink.me
* start date: Sep 3 00:00:00 2020 GMT
* expire date: Oct 3 12:00:00 2021 GMT
* subjectAltName: host "go.onelink.me" matched cert's "*.onelink.me"
* issuer: C=US; O=Amazon; OU=Server CA 1B; CN=Amazon
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x55a1fa81e560)
> GET /2838942512/4fd5c0b1?af_qr=true HTTP/2
> Host: go.onelink.me
> accept: */*
> user-agent: Mozilla/5.0 (Linux; Android 7.1.2; AFTMM Build/NS6265; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/70.0.3538.110 Mobile Safari/537.36
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 128)!
< HTTP/2 302
< content-type: application/octet-stream
< content-length: 0
< location: market://details?id=com.sainsburys.ssa&referrer=af_tranid%3D43Pr6sUII7ARDHawC_t-UA%26af_qr%3Dtrue%26shortlink%3D4fd5c0b1%26pid%3DQR_code%26c%3DSS%20Poster
< date: Sun, 28 Mar 2021 14:53:08 GMT
< server: http-kit
< strict-transport-security: max-age=31536000; includeSubDomains
< x-cache: Miss from cloudfront
< via: 1.1 95e275e2550c87aeaa644f1f37b346e0.cloudfront.net (CloudFront)
< x-amz-cf-pop: LHR50-C1
< x-amz-cf-id: KLK5uQEx8lkFSkLib7CaksPVCyDFKZmAUi5K7cPSi2Y6hH3zIgAr9w==
<
* Connection #0 to host go.onelink.me left intact

The location value is now:

market://details?id=com.sainsburys.ssa&referrer=af_tranid%3D43Pr6sUII7ARDHawC_t-UA%26af_qr%3Dtrue%26shortlink%3D4fd5c0b1%26pid%3DQR_code%26c%3DSS%20Poster

market:// being a special type of URL that will trigger the Google Play Store to open on an Android device and then automatically direct you to the Sainsbury’s SmartShop app page to install.

When I decode the URL encoded parts it’s a little easier to read the parameters.

market://details?id=com.sainsburys.ssa&referrer=af_tranid=43Pr6sUII7ARDHawC_t-UA&af_qr=true&shortlink=4fd5c0b1&pid=QR_code&c=SS Poster

Breaking down the URL parameters

  • referrer— AppsFlyer specific tracking property, transaction ID, possibly for remarketing or tracking the actions of a single user?
  • af_qr — A query parameter for tracking QR code actions, as seen on the original URL
  • shortlink — This value matches part of the original path for the go.onlink.me URL
  • pid — Defines the media source, apparently an absolute requirement for any AppsFlyer link
  • c— Identifies the campaign this is related to.

It is interesting for Android, Sainsbury’s have even broken down the QR code poster as a campaign from a reporting perspective. The shortlink parameter is also based off the second part of the original URL. You can omit this entirely and it will still work.

We can actually get a bit more insight into a lot of these parameters from AppsFlyer themselves (thanks!). What we can deduce is it’s all related to attribution and analytics.

Why is scanning the QR code now required for any mobile app SmartShop sessions?

It makes sense for those who are installing it for the first time as in addition to the relevant App Store data, you can get more insights from customers who install the app directly in-store. Up until recently, these QR code posters were not present as far as I was aware.

Now seeing some of the analytics and tracking behind the QR code, it suggests to me Sainsbury’s are possibly doing some deeper analysis and tracking on their SmartShop mobile users both new and existing. Which again, shouldn’t be surprise. You’ve seen what data is hiding behind a simple QR code, you know Sainsbury’s are going to be data mining your shopping basket for any current and future developments and opportunities!

My mother thought the appearance of the QR codes was possibly related to linking the SmartShop to a specific store, however while that’s a good theory, I think the app can already do this through the geolocation APIs of an Apple or Android device. Equally it would also mean generating unique QR codes per store, which would be very hard to keep track of.

Sainsbury’s probably don’t expect their typical customers to decode their QR codes and do a bit of analysis on what’s happening, but I’m me and I like finding out things and what’s happening behind the scenes. I’ve also probably created some data anomalies in their analytics with some weird User-Agent requests and the fact they all went through a VPN, but I’m sure they’ll be fine!

I'm a web developer, but also like writing about technical networking and security related topics, because I'm a massive nerd!

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store