wherefrom_builder — build Play install-referrer links on a server
Pure Dart, no Flutter, no runtime dependencies. Builds the Google Play
referrer= value and store URL for a deferred deep link — the builder
half of the contract whose app half is InstallReferrer in the Flutter
plugin
wherefrom.
import 'package:wherefrom_builder/wherefrom_builder.dart';
final referrer = PlayStoreUrl.appendPath(
'utm_source=share&utm_medium=social&utm_campaign=spring', // as configured
'/invite/abc123', // or null
allow: ['/invite/', '/items/'], // same list as the app
);
// utm_source=share&utm_medium=social&utm_campaign=spring&utm_content=%2Finvite%2Fabc123
final url = PlayStoreUrl.webFromReferrer(
packageName: 'com.example.app',
referrer: referrer,
);
// https://play.google.com/store/apps/details?id=com.example.app
// &referrer=utm_source%3Dshare%26utm_medium%3Dsocial%26utm_campaign%3Dspring
// %26utm_content%3D%252Finvite%252Fabc123
Why a server needs it
A deferred deep link has two halves. The app reads the referrer, decodes the
value once, checks an allowlist and routes. Something else — a redirect
server, an edge function, a CLI that prints campaign links — decides what
goes into referrer=. When the two disagree nothing fails visibly: Play
counts the install, the app reads an attributed referrer, finds no usable
path, and the user lands on the home screen.
The Flutter plugin already has a correct builder, but a backend cannot
import a Flutter package: dart pub get fails in an image that only has the
Dart SDK. So the builder gets rewritten by hand, which is where "encoded
twice" and "not encoded at all" come from. This package is that builder,
importable from a shelf server, a Cloud Run job or a CLI, and tested
against the same vector file as the app-side parser.
Install
dart pub add wherefrom_builder
To track main before a release, or to pin a commit:
dependencies:
wherefrom_builder:
git:
url: https://github.com/saimskywalker/wherefrom
path: packages/wherefrom_builder
ref: main # or a commit SHA
Coming from imaji_referrer_builder
This package was imaji_referrer_builder up to 0.1.1. Replace the
dependency with wherefrom_builder: ^0.2.0 and the import with
package:wherefrom_builder/wherefrom_builder.dart. Nothing else changed.
The five rules
PlayStoreUrl.appendPath enforces all of them; a hand-written builder
should too.
- Append, never replace. The path rides in its own key (
utm_contentby default) next to the configured campaign keys. The configured string is kept byte for byte — not parsed and re-encoded — so+stays+,%20stays%20, key order and repeated keys stay as they are, and store-console campaign reporting is unaffected. - The payload does not depend on a campaign being configured. An empty
campaign plus a path gives
utm_content=%2Finvite%2Fabc123. - One key, once. The app keeps the first occurrence of a repeated
key, so appending
utm_contentto a campaign that already holds one silently loses the path.appendPaththrows instead (also for an encodedutm%5Fcontentor a bareutm_contenttoken). - Emit only paths the app routes. A path outside the app's allowlist
is counted as an install and lands on home.
appendPaththrows anArgumentErrornaming the reason, with the same rules asdeepLink(allow: …, hosts: …):/itemsallows/itemsand/items/42, never/itemsX;//host,./..segments, backslashes and control characters are refused; an absolute URL only ashttpson one ofhosts. It is stricter in two places (surrounding whitespace, and a dot segment the URI parser would silently remove), because emitting either is a bug. - A target that is not public carries no path. The referrer is visible
in the URL and Play keeps it for 90 days. For something deleted, private
or hidden, pass
null(ortargetIsPublic: false) and you get a campaign-only link that does not name it.
A server bug should fail in the server's tests, so the builder throws; the app side never does.
API
| Member | What it does |
|---|---|
PlayStoreUrl.appendPath(campaign, path, {allow, hosts, key, targetIsPublic}) |
Referrer string: campaign unchanged, plus key=<path encoded once>. Throws ArgumentError on a refused path, a campaign that already holds key, a campaign starting with ?, or an empty key. |
PlayStoreUrl.checkPath(path, {allow, hosts}) |
PathRefusal? — why the app would not route path, or null. For servers that prefer to fall back to a campaign-only link over throwing. |
PlayStoreUrl.expectedDeepLink(path, {allow, hosts}) |
The Uri the app will route (/items/a b → /items/a%20b; an allowed https URL → its path), or null. Handy in server tests. |
PlayStoreUrl.webFromReferrer / marketFromReferrer |
https://play.google.com/store/apps/details?… / market://details?… with the referrer encoded once as the referrer parameter. |
PlayStoreUrl.web / market / encodeReferrer (map form) |
Identical to the Flutter plugin's PlayStoreUrl, byte for byte. |
PathRefusal |
empty, surroundingWhitespace, forbiddenCharacter, malformed, notAPath, hostNotAllowed, unsafe, notAllowlisted. |
Refusing without throwing:
final ok = item.isPublic &&
PlayStoreUrl.checkPath(item.path, allow: allow) == null;
final referrer = PlayStoreUrl.appendPath(
campaign,
ok ? item.path : null,
allow: allow,
);
The encoding contract
path /items/a b
inner (value, once) utm_content=%2Fitems%2Fa+b <- what Play returns to the app
outer (param, once) referrer=utm_content%3D%252Fitems%252Fa%2Bb
app: one decode /items/a b -> routes /items/a%20b
- The inner value is
application/x-www-form-urlencodedonce: letters, digits and-._~stay, a space becomes+, everything else is%XXof its UTF-8 bytes. So a literal+in a path becomes%2Band survives. - The outer layer encodes the whole referrer once more as the
referrerquery parameter, the same way. - Play removes the outer layer; the app removes the inner one, exactly
once. A path that really contains
%2F(/items/a%2Fb) is sent as%252Finside and reaches the app intact.
Test your builder against the shared vectors
test/vectors/referrer_vectors.json
holds rows of campaign + path → referrer_as_play_returns_it,
play_url_referrer_param, play_store_url and expected_deep_link, plus
refused rows a builder must reject. This package's tests and the Flutter
plugin's tests both read it, so the builder and the parser cannot drift
apart. A builder in any other language can pin the same file — fetch
https://raw.githubusercontent.com/saimskywalker/wherefrom/<tag-or-commit>/packages/wherefrom/test/vectors/referrer_vectors.json
in its tests. Tags from before the rename keep it elsewhere:
packages/imaji_install_attribution/test/vectors/ at v0.2.2, and
test/vectors/ at v0.2.1, imaji_referrer_builder-v0.1.1 and older. A
Python builder that passes every row:
import json
from urllib.parse import quote_plus, urlencode
def append_path(campaign, path, key="utm_content"):
if path is None: # also for a target that is not public
return campaign
pair = quote_plus(key, safe="") + "=" + quote_plus(path, safe="")
if campaign == "" or campaign.endswith("&"):
return campaign + pair
return campaign + "&" + pair
def play_store_url(package_name, referrer):
query = {"id": package_name}
if referrer:
query["referrer"] = referrer
return "https://play.google.com/store/apps/details?" + urlencode(query)
def test_vectors():
doc = json.load(open("referrer_vectors.json", encoding="utf-8"))
for row in doc["vectors"]:
referrer = append_path(row["campaign"], row["path"], doc["key"])
assert referrer == row["referrer_as_play_returns_it"], row["name"]
assert play_store_url(doc["package_name"], referrer) == row["play_store_url"]
(The allowlist and duplicate-key checks are left out for brevity; the
refused rows test them.) In JavaScript neither built-in matches byte for
byte: encodeURIComponent leaves !'()* unescaped and writes a space as
%20, and URLSearchParams leaves * and escapes ~. This one passes
every row:
const encode = (s) =>
encodeURIComponent(s)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
.replace(/%20/g, '+');
Migrating
- From hand-built store URLs. Replace string concatenation such as
'$campaign&utm_content=${Uri.encodeComponent(path)}'or a secondUri.encodeComponentaround the whole referrer withappendPath+webFromReferrer, then run the vectors in your tests. The usual findings: the path encoded twice (the app sees%2Finvite…and routes nothing), not encoded at all (works until a path has&,=,%or+), or a campaign that already carriedutm_content. - From a hosted deferred-link SDK. Link vendors built the store URL for
you; when moving link generation in-house, this package is that part.
Use the same
allowlist the app passes todeepLink(), keep the campaign string your marketing team configured, and send hidden or deleted targets as campaign-only links. - From the Flutter plugin's
PlayStoreUrl. The map form (web,market,encodeReferrer) is identical, so a server can switch to this package without changing a single emitted byte.
License
MIT — see LICENSE.
Libraries
- wherefrom_builder
- Google Play install-referrer strings and store links for deferred deep links, built on a server, in pure Dart.