How long each paired tester actually has your app in the foreground, on their own phone. Google asks a personal developer account for 12 testers over 14 days, and can reject the app for “insufficient tester engagement” without naming anyone. Play Console gives you a count; this gives you the testers behind it, and both sides see the same number — so you know who is there before Google tells you nobody was.
It does nothing else. No login, no Cloud project, no Play Console permission, no data from inside your app. Android only; an install that did not come through a developerpact link — every production user you will ever have — never touches the network.
Measured on the tester’s own device
One number per paired tester per day
60 foreground seconds make the day count
I would join if…
What would you need? One line; read only by the operator, counted per member.
Sign in to send one — wishes are counted per member.
One file with the purpose, every step, the Play Console text and the final check — written for a coding agent with no other context. Paste this into it:
Read https://developerpact.com/sdk/rest.md and follow it to integrate the developerpact reporter into this app. Do the code steps yourself and build. For anything the document marks as needing the human — Play Console, uploading the build, the phone test — stop and give me the exact instructions from the document.
The same steps, below, each with the one thing to copy. The README and the page say the same things in the same order, so you can switch between them at any step.
Two HTTP calls, and a foreground timer between them. It is Android work whatever the app is written in — the token arrives through Play or through a devpact:// intent, and what you report is seconds in the foreground. Three values live in app-private storage for the life of the install: an installId, an installSecret, and the day you are counting.
1
Get the token onto the device
A tester opens their own install link on their phone, and that link carries a token: 22 characters, minted for one tester on one testing. Play keeps it through the install and hands it to your app on first launch as utm_content, and for an app the tester already has, the tester page opens a deep link carrying the same thing. That token is the only thing that ties an install to a person — there is no login, and the server never learns who is holding the phone.
Source A — a fresh install: the Play Install Referrer
The install link sends the phone to Play with a referrer attached. Read it once, on first launch, and act only when utm_source is developerpact.
// build.gradle: implementation "com.android.installreferrer:installreferrer:2.2"
InstallReferrerClient client = InstallReferrerClient.newBuilder(context).build();
client.startConnection(new InstallReferrerStateListener() {
@Override public void onInstallReferrerSetupFinished(int code) {
String token = null;
try {
if (code == InstallReferrerResponse.OK) {
// "utm_source=developerpact&utm_content=Qm9yaW5nVG9rZW4xMjM0NQ"
String referrer = client.getInstallReferrer().getInstallReferrer();
// Some paths hand it back still encoded; decode first or the query
// below has no literal '=' and every lookup returns null.
if (referrer.contains("%3D") || referrer.contains("%26")) {
referrer = java.net.URLDecoder.decode(referrer, "UTF-8");
}
Uri parsed = Uri.parse("https://x/?" + referrer);
if ("developerpact".equals(parsed.getQueryParameter("utm_source"))) {
token = parsed.getQueryParameter("utm_content");
}
}
} catch (Exception ignored) {
// Leave the token null: nothing readable came back from Play.
}
client.endConnection();
if (token == null) {
// No token: a production user. Remember it, never read the referrer again.
prefs.edit().putBoolean("referrerChecked", true).apply();
} else {
// Step 2. pair() writes referrerChecked itself, and only once the server
// has answered — never after a timeout, so an offline first launch reads
// the referrer again next time. Play keeps it for about 90 days.
pair(token, "referrer");
}
}
@Override public void onInstallReferrerServiceDisconnected() { }
});
Anything else — organic, (not set), FEATURE_NOT_SUPPORTED, SERVICE_UNAVAILABLE, a timeout — means this install has no token from Play and never will: write the flag and never read the referrer again. Every production user you will ever have takes this branch. The flag gates the referrer and nothing else — the deep link in source B still pairs this install at any time.
Watch out. Write the flag when there is no token, or when the server has actually answered — never after a failed request. A first launch in airplane mode is the ordinary case, and a client that marks the referrer read before pairing succeeds loses that tester their install for good, while Play would have handed the same referrer back on the next launch for about 90 days.
Source B — already installed: the deep link
A tester who already has the app cannot be reached by a referrer; Play never delivers one to an app that is installed. So the tester page opens an intent instead, from a real tap:
Your activity receives devpact://com.example.myapp/pair?t=Qm9yaW5nVG9rZW4xMjM0NQ — scheme devpact, host your applicationId, path /pair, the token in t. Declare it, check the token’s shape, store it, pair, and get out of the way:
// PairActivity.onCreate — the intent is devpact://com.example.myapp/pair?t=Qm9yaW5nVG9rZW4xMjM0NQ
Uri data = getIntent().getData();
String token = data == null ? null : data.getQueryParameter("t");
if (token != null && token.matches("[A-Za-z0-9_-]{22}")) {
prefs.edit().putString("pendingToken", token).apply(); // write it down before anything can fail
pair(token, "deeplink"); // works with the app only in the background
}
startActivity(getPackageManager().getLaunchIntentForPackage(getPackageName()));
finish();
${applicationId} resolves at build time, so the intent can only reach your app. If the installed build has no such activity the browser falls back to the tester page with ?state=needs-update, which is what a tester on an old build sees.
On a stack with no Install Referrer library, source B is the whole mechanism.
Skip A entirely and have every tester tap Pair this phone on their tester page after installing. Nothing downstream changes except pairedVia. You lose only the pairing that would have happened without a tap.
Watch out. The token arrives before there is anywhere to put it. The referrer is readable on the very first launch, and the intent can arrive while your app is alive only in the background — in both cases write the token to storage first and pair second, or a process death between the two loses that tester their install.
The two formats, exactlyWhat is in the link, and why it is shaped like that
the tester page with ?state=needs-update, for a build with no pairing activity
The utm_ shape is deliberate: a host app’s own analytics (Firebase, AppsFlyer, Branch, Adjust) sees a named source instead of garbage, and reading the referrer does not consume it, so several readers can coexist. Play keeps it readable for about 90 days after the install.
A referrer is not proof of a Play install — the same package sideloaded after the link was tapped gets one too. The proof is initiatingInstaller in step 2.
2
Pair once
One call, once per install, no authentication: the token is the credential. It returns the secret every later call needs.
Run that as it stands and the sample token earns the 404 below. Paste in a real token — the one in your own tester link — and it pairs a real install, so use an installId you are willing to keep.
The body, field by field
Field
What it is
Where it comes from
If it is wrong
v
1
Protocol version. There is one.
The constant 1.
Any other value is a 422.
pkg
string
Your applicationId.
BuildConfig.APPLICATION_ID.
A package that is not the one registered for this token's app is a 404, and the token is left unspent.
token
string
The install token from step 1. It is the credential for this one call and nothing else.
utm_content in the Install Referrer, or t in the devpact:// link.
Unknown or foreign: 404. Expired, rotated or already spent: 410 token_expired. Neither creates anything.
installId
string
A UUID v4 naming this install of your app on this device.
Generate it once, on first run, and keep it beside the secret in app-private storage.
A new id every launch reads as a reinstall: more than 2 on one testing flags it suspicious. An id that already paired with another testing is a 404 for good — it never moves.
pairedVia
"referrer" | "deeplink"
Which of the two paths delivered the token.
You know which one you took.
Recorded and shown to the owner; a wrong value only misleads them.
installer
string | null
The package that installed you.
getInstallSourceInfo(getPackageName()).getInstallingPackageName() on API 30+, getInstallerPackageName below it. No package-visibility entry is needed for your own package.
Anything but com.android.vending shows on the owner's ledger as "sideloaded" and earns no active day, ever. Send null when Android will not say rather than guessing.
initiatingInstaller
string | null
The package that started the install, which setInstallerPackageName cannot rewrite. This is the field that makes a Play install a Play install.
getInstallSourceInfo(getPackageName()).getInitiatingPackageName() on API 30+; null below 30.
Not com.android.vending and no day from this install can ever count, whatever the heartbeats say.
appVersionCode
integer
The build the tester is running.
PackageInfo.getLongVersionCode(), or versionCode below API 28.
A stale or invented value makes the owner's version-lag hint lie; above 2^31−1 is a 422.
sdk
string
Who is reporting, as name/version.
A constant in your client, e.g. "myapp-android/1.0.0". The plugin sends "flutter/<version>".
A string with no dotted version counts as below any minimum an operator sets, and that answer is 410 stop.
osApi
integer
Android API level, so the ledger can read the installer fields correctly.
installSecret is 43 characters and is returned exactly once: the server keeps only its SHA-256, so it cannot be sent again, ever. Write it to app-private storage before you do anything else. Keep rules.thresholdSeconds too — it is the app’s own bar for a day that counts, and serverDay is a free check on your own calendar.
Paired. The secret is returned exactly once and stored on the server only as SHA-256.
Store the secret in app-private storage, drop the token, start measuring.
404
{ ok: false, error: "not found" }
Unknown token, token for another package, or this install id already belongs to a different testing. One fixed answer for all of them.
Forget the token and stay silent. Do not retry with the same one.
410
{ stop: true }
The testing is over, or the client version in the body is below the minimum the server accepts — the kill switch. The token is left unspent.
Stop for good: write a stopped flag and send nothing until a new deep-link token arrives.
410
{ stop: false, reason: "token_expired" }
The token expired (30 days), was rotated away, or was spent (2 installs). Not the kill switch.
Stay unpaired. The tester page always shows a fresh token; a new deep link pairs.
422
{ ok: false, error: "malformed body" }
The body did not match the strict schema — an unknown field is a 422, not a shrug.
Log once and drop the request. Retrying an unchanged body cannot help.
5xx, or no answer
— (or a 200 with no installSecret in it)
The server or the network failed: a deploy behind the proxy, a timeout, a first launch with no connectivity. The token was not spent.
Change nothing — keep the token, leave the referrer unchecked, and try again at the next foreground start.
Only the 422 is a bug in your client. 410 {stop: true} is the kill switch — the testing ended, or an operator retired your sdk version — and means stop for good, but keep the deep-link activity alive so a new token can revive the install. 410 token_expired is not the kill switch: the tester page always shows a fresh token, so a new deep link pairs. The 404 is one fixed answer for an unknown token, a token minted for another package, and an install id that already belongs to a different testing; a probe learns nothing from it and neither will you, so do not retry. The last row is the opposite rule and the one that is easy to miss: a 5xx or a dead connection is not an answer, so nothing is spent and nothing is forgotten.
Watch out. The secret is not the token. The token is public enough to ride in a URL and is spent by this call; the secret is a credential shown once that nothing can fetch again. Lose it and the only way back is a new deep-link token — which is exactly why a 401 later must never make your client throw the pairing away.
JSON Schema for the pair bodyGenerated from the server’s own validators
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"v": {
"type": "number",
"const": 1,
"description": "Protocol version. Always 1."
},
"pkg": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$",
"description": "The host app's applicationId. Must equal the app's registered package name."
},
"token": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{22}$",
"description": "Install token from the referrer (utm_content) or the deep link (t). 16 random bytes, base64url."
},
"installId": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"description": "UUID v4 the client generates once per install and keeps in app-private storage."
},
"pairedVia": {
"type": "string",
"enum": [
"referrer",
"deeplink"
],
"description": "Which path delivered the token."
},
"installer": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 255
},
{
"type": "null"
}
],
"description": "installingPackageName (API 30+) or getInstallerPackageName; null when Android could not say. Play is com.android.vending."
},
"initiatingInstaller": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 255
},
{
"type": "null"
}
],
"description": "initiatingPackageName (API 30+), which setInstallerPackageName cannot forge; null below API 30."
},
"appVersionCode": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"description": "The host's versionCode."
},
"sdk": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "Client identifier, \"<name>/<x.y.z>\" — the plugin sends \"flutter/<version>\". A string with no dotted version counts as below any minimum the server sets (410 stop)."
},
"osApi": {
"type": "integer",
"minimum": 1,
"maximum": 999,
"description": "Build.VERSION.SDK_INT."
},
"tzOffsetMin": {
"type": "integer",
"minimum": -900,
"maximum": 900,
"description": "Local minus UTC in minutes (Istanbul +180, New York in September −240). Optional on pair, required on heartbeat."
}
},
"required": [
"v",
"pkg",
"token",
"installId",
"pairedVia",
"installer",
"initiatingInstaller",
"appVersionCode",
"sdk",
"osApi"
],
"additionalProperties": false
}
3
Measure a day
One number per day: the seconds your app spent in the foreground. “Foreground” means your window is STARTED — split-screen and picture-in-picture count, a screen that turns off does not. Count it from a monotonic clock (SystemClock.elapsedRealtime()), never from wall time, so a tester changing the clock cannot add or lose seconds.
The day is the device’s local calendar day, and it is the day the session started on. A session that runs from 23:50 to 00:20 belongs entirely to the day it began; the next day starts counting at the next foreground start. Send the offset you used as tzOffsetMin — that is how the server works out what today is for this device.
The value you send is cumulative, not incremental: the day’s running total so far, every time. The server merges with max and never sums, which is what makes a retry, a duplicate or a resend after a crash harmless — there is no idempotency key and no acknowledgement protocol to get right. It is also what makes losing a heartbeat cost nothing: the next one carries the same day’s total again.
session cap
4 hours in one session — cap it on the device
day cap
8 hours in one day; the server refuses anything above it as over_cap and counts none of it
threshold
from the pair response, default 60 s — at or above it, the day counts
unsent days
keep at most 7; the server’s accepted window is the same length
checkpoint
write the bucket to storage every 20 s while visible, so a force-kill loses seconds, not a day
A day also has to be a Play install to count: installer and initiatingInstaller must both be com.android.vending. Your own sideloaded debug build will pair and report perfectly and still never produce an active day — that is the rule, not a bug.
Watch out. The day is the device’s, not UTC. Format YYYY-MM-DD from the tester’s own clock; a client that formats in UTC posts every evening session in the Americas under tomorrow’s date, and the ledger shows a tester who is somehow always active one day ahead and never today.
4
Send it
The same body every time, authorised with the secret from step 2: authorization: Bearer <installSecret>. No signature, no nonce — TLS covers integrity, and cumulative-max makes a replay pointless.
Export the secret from step 2 first (export INSTALL_SECRET=…), then replace installId, day and sentAt with your own — the sample values earn a 401 without the secret, then a 404 for an install the server never saw, then a 422 once the baked day ages out of the window.
The body, field by field
Field
What it is
Where it comes from
If it is wrong
v
1
Protocol version. There is one.
The constant 1.
Any other value is a 422.
pkg
string
Your applicationId, again.
BuildConfig.APPLICATION_ID.
Not the package this install paired with: 404.
installId
string
The same UUID you paired with. It, not the secret, says which install this is.
App-private storage.
Unknown id: 404 — clear the pairing and pair again.
day
string
The local calendar day this bucket belongs to: the day the session started on.
The device's own clock, formatted YYYY-MM-DD — not UTC.
Outside [serverDay − 7, serverDay + 1] is a 422 carrying serverDay. A day that is really UTC quietly reports the wrong one every evening west of Greenwich.
tzOffsetMin
integer
The offset you used to compute day. Required here.
It is how the server works out what today is for this device, so a wrong offset moves the whole accepted window.
fgSeconds
integer
The day's cumulative foreground seconds — the running total, never a delta.
Your own counter from step 3.
Above the 8-hour day cap: recorded as over_cap, not counted. A jump larger than the time that has actually passed: impossible_delta, not counted, and the testing is flagged.
sessions
integer
Foreground sessions counted for that day so far. Cumulative too.
Your own counter.
Merged with max like fgSeconds, so a value lower than one already stored is simply ignored.
appVersionCode
integer
The build the tester is running.
PackageInfo.getLongVersionCode(), or versionCode below API 28.
A stale or invented value makes the owner's version-lag hint lie; above 2^31−1 is a 422.
sdk
string
Who is reporting, as name/version.
A constant in your client, e.g. "myapp-android/1.0.0". The plugin sends "flutter/<version>".
A string with no dotted version counts as below any minimum an operator sets, and that answer is 410 stop.
osApi
integer
Android API level, so the ledger can read the installer fields correctly.
Build.VERSION.SDK_INT.
Outside 1–999 is a 422.
installer
string | null
The package that installed you, checked again on every heartbeat and not only at pairing.
getInstallSourceInfo(getPackageName()).getInstallingPackageName() on API 30+, getInstallerPackageName below it. No package-visibility entry is needed for your own package.
Anything but com.android.vending shows on the owner's ledger as "sideloaded" and earns no active day, ever. Send null when Android will not say rather than guessing.
sentAt
string
When you sent this, ISO-8601 with an offset.
The device's clock.
More than ten minutes from the server's clock is recorded as clock_skew — an annotation, not a refusal: the value still counts.
When to send — twice, and never otherwise
At foreground start: one request per unsent day you are holding (up to 7), plus today’s partial value. This is the catch-up for a tester who was offline.
The first time today crosses thresholdSeconds: one request, so the day counts even if the app is force-killed a second later.
Nothing on background, nothing on a timer, nothing scheduled: on onStop you write to storage and send on the next launch. The retry policy is the next launch too — no backoff loop, no work manager, no wake-ups. At most 120 requests an hour from one install are accepted; a client that respects the two moments above will never see that number.
Every answer, and what to do about it
Status
Body
What it means
What your client does
200
{ ok: true, activeToday, serverDay }
Recorded (or recorded as rejected — see below; the status is 200 either way).
Mark the day sent. Compare serverDay with your own day and resync if they differ.
401
{ ok: false, error: "unauthorized" }
Bearer missing, malformed, or not this install's secret.
Do nothing — keep the pairing and try again next launch. A secret cannot be re-fetched, so never clear it on a 401.
404
{ ok: false, error: "not found" }
Unknown installId, or the package does not match the install.
Clear the pairing (secret and install id) and pair again from a new link.
410
{ stop: true }
The install is stopped (its testing ended, or the client version was retired).
Stop for good; keep the deep-link entry point alive so a new token can revive it.
422
{ ok: false, error: "malformed body" }
Strict schema.
Log once and drop the request.
422
{ ok: false, error: "day out of window", serverDay }
day is outside [serverDay − 7, serverDay + 1].
Drop that day's bucket and resync your calendar from serverDay. It will never be accepted.
429
{ ok: false, error: "rate limited" }
More than 120 heartbeats an hour from one installId, counted before any database read.
Retry at the next foreground start, not sooner. Nothing is lost — the value is cumulative.
5xx, or no answer
—
The server or the network failed: a deploy behind the proxy, a timeout, a tester with no signal. The server holds nothing new.
Change nothing — keep the day's bucket unsent and send it again at the next foreground start.
Watch out. A 200 is not a promise that the seconds counted. A heartbeat that breaks a rule is still recorded and still answered with 200, because there is nothing a client could usefully do about it — the reasons are below, and they show up on the owner’s ledger, not in your logs.
Rejections recorded on a 200Four reasons a day can be refused without an error
A heartbeat that fails a rule gets a 200 anyway: the day document records the reason, the counters move, and the value is or is not folded in. Your client does not need to know — the ledger and the owner’s mail show it.
Code
When
The value
clock_skew
sentAt is more than 10 minutes from the server's clock.
still counted — annotation only
over_cap
fgSeconds above the 8-hour day cap.
not counted
late_duplicate
A past day (older than yesterday) that this install already reported, or yesterday revised by more than the elapsed time allows.
not counted
impossible_delta
Same-day increase larger than the wall-clock time since the last accepted heartbeat + 90 s.
not counted; the testing is flagged suspicious
Three of the four are things an honest client never does. clock_skew is the exception: it is an annotation, the value still counts, and the day window already bounds what a wrong clock can claim.
JSON Schema for the heartbeat bodyGenerated from the server’s own validators
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"v": {
"type": "number",
"const": 1,
"description": "Protocol version. Always 1."
},
"pkg": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$",
"description": "The host app's applicationId. Must equal the app's registered package name."
},
"installId": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"description": "UUID v4 the client generates once per install and keeps in app-private storage."
},
"day": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"description": "The device's local calendar day the bucket belongs to — the day the session started."
},
"tzOffsetMin": {
"type": "integer",
"minimum": -900,
"maximum": 900,
"description": "Local minus UTC in minutes (Istanbul +180, New York in September −240). Optional on pair, required on heartbeat."
},
"fgSeconds": {
"type": "integer",
"minimum": 0,
"maximum": 604800,
"description": "Cumulative foreground seconds for `day`. The server keeps the max, never a sum."
},
"sessions": {
"type": "integer",
"minimum": 0,
"maximum": 100000,
"description": "Foreground sessions counted for `day` so far."
},
"appVersionCode": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"description": "The host's versionCode."
},
"sdk": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "Client identifier, \"<name>/<x.y.z>\" — the plugin sends \"flutter/<version>\". A string with no dotted version counts as below any minimum the server sets (410 stop)."
},
"osApi": {
"type": "integer",
"minimum": 1,
"maximum": 999,
"description": "Build.VERSION.SDK_INT."
},
"installer": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 255
},
{
"type": "null"
}
],
"description": "installingPackageName (API 30+) or getInstallerPackageName; null when Android could not say. Play is com.android.vending."
},
"sentAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"description": "ISO-8601 with offset, the device's clock. More than 10 minutes off the server's is recorded as clock_skew."
}
},
"required": [
"v",
"pkg",
"installId",
"day",
"tzOffsetMin",
"fgSeconds",
"sessions",
"appVersionCode",
"sdk",
"osApi",
"installer",
"sentAt"
],
"additionalProperties": false
}
5
Prove it works
There is one check, and it is not a log line: the first heartbeat from your own install. Register your app, start the self-test from its ledger, install your closed-track build through the install link that card gives you, and open the app for a minute.
The self-test card turns green, the app is marked SDK verified, and that is what lets it open for testers — an app whose integration has never sent a heartbeat cannot be joined. You are also, at that point, looking at exactly what your testers will see.
Paired but nothing appears? Three usual causes, in order: the install was not a Play install (com.android.vending in both installer fields — a sideloaded build never counts); fgSeconds never reached thresholdSeconds; or day was formatted in UTC and landed on the wrong date. The ledger shows which of the three it was.
Transport, headers and error styleEverything true of both calls
Base URL
https://developerpact.com/api/sdk/v1
Method
POST, JSON body, content-type: application/json. Nothing else is accepted.
Schemas
Strict — an unknown field is a 422, not a shrug, so the contract stays small enough for both sides to be sure of it.
Auth
None on /pair; Authorization: Bearer <installSecret> on /heartbeat. Bearer rather than HMAC: TLS gives integrity, cumulative-max makes a replay harmless, and a bearer secret can be stored hashed.
Response headers
cache-control: no-store on every response — the pair 200 carries a secret.
Errors
Fixed strings, so a probe learns nothing from the text: an unknown token and a token for another package read identically.
Timeouts
The plugin uses a 5-second timeout and no retry beyond “try again next launch”. Match it.
Privacy
Never sent: device model, locale, IP geolocation, advertising or App Set ID, account information, app content.
6
Declare it in Play Console, then ship a closed-track build
The two ids in that path are yours and the console fills them in. In the left menu the trail is collapsed by default: open Monitor and improve, then Policy and programs, and App content is inside it.
What to tick, and answer
Data collection and security
→Data types
→Data usage and handling
→Preview
→Submit
Two screens, two actions. On Data types the list is grouped by category, and each row below names the category its checkbox sits in. Every type you tick comes back on Data usage and handling in a dialog of its own, which asks the remaining columns in that order.
Data type
Collected
Shared
Processed ephemerally
Required or optional
Purposes
Device or other IDs › Device or other IDson the Data types screen
Yes
Yes
No
Required
Analytics and App functionality
App activity › App interactionson the Data types screen
Yes
Yes
No
Required
Analytics and App functionality
Every other data type in the form is No. Encrypted in transit: Yes. Deletion available: Yes.
What to paste
developerpact SDK — data disclosure. Collected: (1) Device or other IDs — a random install identifier generated by the SDK; (2) App activity → App interactions — daily foreground time in seconds. Collection is automatic (required, no in-app toggle) and occurs only for installs paired with a developerpact.com testing account (via a testing link or a pairing link); ordinary users generate no traffic. Purpose: Analytics; App functionality. Shared: Yes, with developerpact.com — it uses the data for its own purposes (a cross-developer ledger), so Google's service-provider exemption does not apply. Encrypted in transit: Yes. Deletion: Yes — testers delete their data at developerpact.com/me. Not collected: names, emails, location, device identifiers, advertising ID, contacts, app content. Merged Android permissions: android.permission.INTERNET, com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE.
During closed testing this app uses the developerpact SDK, which reports a random install identifier and daily foreground usage time to developerpact.com for testers who joined through that platform; see developerpact.com/privacy.
The first goes in the form’s free-text field; the second is one sentence added to the privacy policy you already publish.
Why: Google makes you, the host developer, declare what every SDK inside your build collects. These two rows are the whole of what this one sends.
Then ship a closed-track build. Nothing pairs until an install arrives through a developerpact link.
Why Shared is YesThe service-provider exemption does not apply
Google exempts a “service provider” that processes data only on the developer’s behalf. developerpact.com does more than that: the same numbers feed a ledger every other member reads, and the reliability counters that follow a tester from app to app. That is a purpose of its own, so the exemption does not apply and the honest answer is Yes.
The Android permissions this adds, for the permissions part of the same form — the second only if you read the Install Referrer, and it comes from Google’s own library:
The conservative reading: User IDsNot ticked here, and why adding it costs nothing
A more conservative reading adds Personal info → User IDs, because the pairing token is tied to an account on the platform. The default here is not to add it, and the reason is in what the SDK can see: it never receives the account, the e-mail or any name — only a random 22-character token that the server maps to a testing, and later a random install id. The mapping lives on developerpact.com, which declares it in its own policy. If your reviewer, your lawyer or your own reading says otherwise, adding the row costs nothing and contradicts nothing above.
After production: remove it, or keep it honestlyThe declaration stays true either way
After production is granted: delete your pairing activity and its <intent-filter>, the referrer read, the foreground timer and the two POSTs, ship an update, and take the two rows and the sentence back out. The activity is the piece to be sure of — devpact:// keeps resolving for as long as it is declared.
Keeping it for the next closed-testing round is fine, and the declaration above stays honest as written. Two properties make that true: an install that was never paired with the platform never touches the network — the referrer is read once, the flag is written, and that is the end of it for that install — and a paired install stops the moment its testing ends (410 {stop: true}). So “occurs only for installs paired with a developerpact.com testing account” is not a promise about your intentions; it is what the code does, and the code is short enough to check.
If something else already reads the Install ReferrerFirebase, AppsFlyer, Branch, Adjust
Multiple readers are fine; the value is not consumed by reading it. Installs through the platform show up in those tools as utm_source=developerpact with the token in utm_content, which is why the link uses the utm_ format rather than something opaque: your analytics sees a named source, not garbage. Nothing about those tools’ own declarations changes.
What this adds to your appFour things, and nothing that runs on its own
android.permission.INTERNET, which most apps already declare. Both calls are ordinary HTTPS POSTs.
Only if you take source A: Google’s com.android.installreferrer:installreferrer:2.2, whose own AAR merges com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE. That permission comes from Google’s library, not from anything here, and skipping source A skips both.
One exported activity of your own — the block in step 1 — reachable only at devpact://<your applicationId>/pair. Keep it invisible and finished before it is drawn, and keep it even after you stop reporting only if a new token should still be able to pair.
Five values in app-private storage: an installId, an installSecret, the day you are counting, and the pendingToken and referrerChecked from step 1.
Nothing runs on its own. No service, no receiver, no WorkManager job, no scheduled work, no wake-up: the client measures while your app is in the foreground and posts at the two moments in step 4. The retry policy is the next launch.
Android only, and only ever Android. There is no iOS side to write — Apple has no closed-testing prerequisite and no Install Referrer — so an app that ships to both stores builds this into the Android half alone and has nothing to add to its App Store privacy form. A day also only counts from a Play install: com.android.vending in both installer fields.
When data is sent, and what is in itSeven moments, and nothing outside them
Moment
What
Why
First launch after a Play install through a developerpact link
one pair request
the Install Referrer carried a token
devpact:// deep link opened from the tester page
one pair request
the tester tapped Pair; works even if the app is only alive in the background
App comes to the foreground while paired
one heartbeat per unsent day (up to 7), plus today's partial value
catch-up for offline days; the retry policy is "next launch"
Today's bucket first crosses thresholdSeconds (default 60 s)
one heartbeat
so the day counts even if the app is force-killed later
App goes to the background (onStop)
nothing
prefs are written; the value is sent on the next foreground start
Never paired, or an organic / production install
nothing, ever
the referrer flag is written once and the SDK stays silent
Server answered 410 {stop: true}
nothing until a new deep-link token
the testing ended; PairActivity stays reachable, everything else is off
Every heartbeat carries the day’s cumulative seconds; the server keeps the max and never sums, so a re-sent day is harmless. On the device: session cap 4 h, day cap 8 h, at most 7 unsent days kept, a checkpoint to SharedPreferences every 20 s while visible. “Foreground” means the app window is STARTED — split-screen and picture-in-picture count, a screen that turns off does not. The bucket day is the local calendar day the session started on.
Not sent, ever: device model, locale, IP geolocation, advertising or App Set ID, account information, app content.
What a tester can and cannot fakeThe honest table, ✓ rows included
The platform is evidence for a person choosing whom to keep, not a proof system; the rows marked ✓ are open by design in v1 and say so.
What
How / why
✗ cannot
A day by screenshot or by saying so
days come only from heartbeats
✗ cannot
Earning a day for someone else with their token
the token is shown only to the signed-in tester and is bound to their testing
✗ cannot
Posting with a random installId
404, no day; a wrong secret is 401
✗ cannot
Speeding time up
the per-day delta must be ≤ elapsed time + 90 s; day window; day cap
✗ cannot
Looking Play-installed after a sideload (the lazy version)
installer reports "sideloaded"
✓ can
Opening the app and leaving it on the table
the rule is "one minute a day"; a minimal_pattern flag (threshold + < 10 s every day, the same minute across apps) informs the owner
✓ can
Actually opening it in an emulator
shows as "device unverified" once Play Integrity lands (Phase 4)
✓ can
Several Google accounts or devices (Sybil)
accepted risk in v1; a co_located flag (same installer + osApi + versionCode, near-identical heartbeat times) informs the owner
✓ can
A determined forger: calling pair from curl with their own token, generating heartbeats, faking installer
accepted risk in v1 — 12 to 50 developers who know each other; a forger only cheats their own partner and contradicts Google's own count. Play Integrity is planned, not promised
Support policy, and removing it afterwards6 months, then one build to take it out
A published SDK version keeps reporting for at least 6 months after the next version ships. The server can retire versions below a minimum; such a build gets 410 {stop: true} and goes silent until the app updates and re-pairs. Removing the pairing code is the local kill switch — nothing on the device survives it: no service, no receiver, no scheduled work, no file outside the app’s own SharedPreferences.
Once Google grants production access:
Delete your pairing activity and its <intent-filter> from the manifest, so devpact:// stops resolving. This is the one that matters: an activity left behind is a live pairing entry point.
Delete the Install Referrer read (and the installreferrer dependency, if nothing else in the app uses it), the foreground timer, and the two POSTs.
Ship an update, and clear the stored installId, installSecret and day buckets on first run of it — or simply stop reading them.
Remove the two Data safety rows and the privacy-policy sentence.
If you would rather keep it in the app for the next closed-testing round, the declaration stays honest as written: installs that were never paired with the platform do not touch the network, and paired installs stop the moment the testing ends. Details on the Data safety step.
Trust artifactsLine counts, a reproducible hash, the merged permissions
The published archive is what the numbers describe, the line counts are measured, the hash is reproducible from a download, and this site’s test suite fails whenever any of it drifts from the package files.
48 files, 174,080 bytes; ustar; entries sorted by path; mtime 0; uid/gid 0; mode 644; no compression. The same file set pub uploads, rebuilt in a form anyone can reproduce bit for bit — pub’s own gzipped archive is not byte-stable, so its hash is not the one to compare.
read from the example app’s merged debug manifest; the third one is androidx.core’s app-private permission, present in every Flutter app. PairActivity merged: yes.
Support
a published version keeps reporting for at least 6 months after the next one ships; a retired build hears 410 {stop:true} and goes silent
Measured by tool/trust.sh inside the package and checked in as trust.json; this site's test suite re-runs the script against the package files and fails when a number here has drifted.
Reproduce the hash
From pub.dev, which needs no repository, no git and no account — the archive for a version never changes. tool/trust.sh is inside it; python3 is all it needs.
mkdir developerpact-0.1.1 && curl -sL https://pub.dev/api/archives/developerpact-0.1.1.tar.gz | tar xz -C developerpact-0.1.1
cd developerpact-0.1.1 && bash tool/trust.sh | grep sha256
Or from the source repository, which is the same files with their history. Its branch moves with each release, so read it there and hash the published archive above.