# developerpact reporter — REST integration for any Android stack
[https://developerpact.com/sdk?path=rest](https://developerpact.com/sdk?path=rest) · this file: https://developerpact.com/sdk/rest.md · base URL `https://developerpact.com/api/sdk/v1`
This document is complete on its own. It says what the developerpact reporter is, why an app under Google Play closed testing adds it, every step to build the client into a native Android, React Native, Unity, Kotlin Multiplatform or other non-Flutter Android app, which steps need a human in Play Console, and the one check that proves it works. Flutter apps have a published package instead — see https://developerpact.com/sdk/flutter.md — and should not take this path.
**What you build:** two HTTPS 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. About half a day of work.
## Why this exists
Google Play requires a personal developer account to run a closed test with **12 testers opted in for 14 consecutive days** before the app may go to production, and it can reject the application for "insufficient tester engagement" without naming anyone. Play Console shows a count of opted-in testers and nothing per tester: no screen and no API says which of them still opens the app.
[developerpact.com](https://developerpact.com) is an exchange where developers test each other's closed-test apps. A small open-source **reporter** inside each app measures how many seconds a day each paired tester has the app in the foreground, on the tester's own device, and reports one number per tester per day. 60 foreground seconds make a day count. Every owner sees which of their testers is actually there, drops the ones who are not, and gets replacements; and the people they test in return see the same numbers about them.
**What the reporter is and is not.** Android only. No login, no Cloud project, no Play Console permission, no data from inside the app. It sends a random install id and daily foreground seconds, and only for installs that were paired through a developerpact link: an install that did not come through such a link — every production user the app will ever have — never touches the network. A paired install stops the moment its testing ends.
**How an install is paired.** A tester opens their personal install link on their phone. For a fresh install the link sends the phone to Play with a referrer that carries a one-time token; on first launch the reporter reads the Play Install Referrer once and pairs. For an app the tester already has, the tester page opens a `devpact://` deep link carrying the same token. The server binds the token to the tester and to the app's registered package name — a token presented from any other package is refused — so the owner never configures anything and the reporter never learns who is holding the phone.
## Before you start
You need:
- An Android app with a **closed testing track** in Play Console. The reporter does nothing on iOS, web or desktop.
- The app's **applicationId** (the Android package name, from `android/app/build.gradle` or `build.gradle.kts`). It must be exactly what is registered on developerpact.com.
- An account on developerpact.com (any Google account) and the app registered there at [https://developerpact.com/apps/new](https://developerpact.com/apps/new): package name, how many testers, the rules a tester signs up to. Registration takes about two minutes and needs no key or token in the app.
- Access to Play Console, for the Data safety form and for uploading the closed-track build.
- An Android phone whose Play Store account is on the app's tester list, for the final check.
**If you are an AI coding agent:** Steps 1 to 4 are Android code and a build; an agent can do them unattended. The Play Console steps and the phone test need the human — when you reach them, stop and hand over the exact text, table and instructions from this file rather than paraphrasing them. There is no configuration to invent: no key, no app id, no initialisation arguments, no manifest edit of your own.
## Step 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 of base64url (16 random bytes), minted for one tester on one testing, e.g. `Qm9yaW5nVG9rZW4xMjM0NQ`. Play keeps it through the install and hands it to your app on first launch as `utm_content`; 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. The value, decoded, is:
```text
utm_source=developerpact&utm_content=Qm9yaW5nVG9rZW4xMjM0NQ
```
and in the store URL:
```text
https://play.google.com/store/apps/details?id=com.example.myapp&referrer=utm_source%3Ddeveloperpact%26utm_content%3DQm9yaW5nVG9rZW4xMjM0NQ
```
Read it **once**, on first launch, and act only when `utm_source` is `developerpact`:
```java
// 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. Decode a still-encoded value (`%3D`, `%26`) first — some paths double-encode. A referrer is **not** proof of a Play install; the proof is `initiatingInstaller` in step 2.
### 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:
```text
intent://com.example.myapp/pair?t=Qm9yaW5nVG9rZW4xMjM0NQ#Intent;scheme=devpact;package=com.example.myapp;S.browser_fallback_url=https%3A%2F%2Fdeveloperpact.com%2Ft%2Fmy-app-1a2b3c%3Fstate%3Dneeds-update;end
```
Your activity receives `devpact://com.example.myapp/pair?t=Qm9yaW5nVG9rZW4xMjM0NQ` — scheme `devpact`, host your applicationId, path `/pair`, the token in `t`. Declare it in your manifest:
```xml
```
Check the token's shape, store it, pair, and get out of the way:
```java
// 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`.
**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.
## Step 2 — Pair once
`POST https://developerpact.com/api/sdk/v1/pair` — one call, once per install, no authentication: the token is the credential. It returns the secret every later call needs.
```sh
curl -sS -X POST https://developerpact.com/api/sdk/v1/pair \
-H 'content-type: application/json' \
-d '{
"v": 1,
"pkg": "com.example.myapp",
"token": "Qm9yaW5nVG9rZW4xMjM0NQ",
"installId": "5f0c2a1e-9b3d-4c7a-8e21-6d4f1b2a9c03",
"pairedVia": "referrer",
"installer": "com.android.vending",
"initiatingInstaller": "com.android.vending",
"appVersionCode": 42,
"sdk": "flutter/0.1.0",
"osApi": 34,
"tzOffsetMin": 180
}'
```
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 | Type | 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/". | 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. |
| `tzOffsetMin` | integer (optional) | Minutes east of UTC — Istanbul is +180. | TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60000. | Optional here. Left out, the pairing day is named from your first heartbeat instead, which is almost always the same day. |
### What comes back
```json
{
"installSecret": "K3q9ZbQ2xW8vL5nR7tY1uC4eH6jM0pA8sD2fG5hJ9kL",
"serverDay": "2026-09-06",
"rules": {
"thresholdSeconds": 60
}
}
```
`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.
### Every answer, and what to do about it
| Status | Body | What it means | What your client does |
| --- | --- | --- | --- |
| 200 | `{ installSecret, serverDay, rules: { thresholdSeconds } }` | 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; do not retry. A 5xx or a dead connection is not an answer: 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 body (generated from the server's own validators)
```json
{
"$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, \"/\" — the plugin sends \"flutter/\". 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
}
```
## Step 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.
| Rule | Value |
| --- | --- |
| 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.
## Step 4 — Send it
`POST https://developerpact.com/api/sdk/v1/heartbeat` — the same body every time, authorised with the secret from step 2: `authorization: Bearer `. No signature, no nonce — TLS covers integrity, and cumulative-max makes a replay pointless.
```sh
curl -sS -X POST https://developerpact.com/api/sdk/v1/heartbeat \
-H 'content-type: application/json' \
-H "authorization: Bearer $INSTALL_SECRET" \
-d '{
"v": 1,
"pkg": "com.example.myapp",
"installId": "5f0c2a1e-9b3d-4c7a-8e21-6d4f1b2a9c03",
"day": "2026-09-06",
"tzOffsetMin": 180,
"fgSeconds": 97,
"sessions": 2,
"appVersionCode": 42,
"sdk": "flutter/0.1.0",
"osApi": 34,
"installer": "com.android.vending",
"sentAt": "2026-09-06T11:02:13.000Z"
}'
```
Export the secret from step 2 first (`export INSTALL_SECRET=…`), then replace `installId`, `day` and `sentAt` with your own.
### The body, field by field
| Field | Type | 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. | TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60000. | 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/". | 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
1. **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.
2. **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. |
A successful answer:
```json
{
"ok": true,
"activeToday": true,
"serverDay": "2026-09-06"
}
```
**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.
| 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 |
### JSON Schema for the heartbeat body (generated from the server's own validators)
```json
{
"$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, \"/\" — the plugin sends \"flutter/\". 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
}
```
## Step 5 — Declare it in Play Console (Data safety) — needs the human
Google makes the host developer declare what every SDK inside the build collects. These two rows are the whole of what this reporter sends.
**Where the form is:** Play Console → All apps → your app → Monitor and improve → Policy and programs → App content → Data safety → Manage
URL pattern (the two ids are yours; the console fills them in):
```text
play.google.com/console/u/0/developers//app//app-content/data-privacy-security
```
In the left menu the trail is collapsed by default: open **Monitor and improve**, then **Policy and programs**, and **App content** is inside it.
**The form is a wizard** with these screens, in order: *Data collection and security* → *Data types* → *Data usage and handling* → *Preview* → *Submit*. On **Data types** the list is grouped by category — tick the two rows below where the table says. Every type you tick comes back on **Data usage and handling** in a dialog of its own, which asks the remaining columns in this order:
| Data type | Collected | Shared | Processed ephemerally | Required or optional | Purposes |
| --- | --- | --- | --- | --- | --- |
| Device or other IDs › Device or other IDs (on the Data types screen) | Yes | Yes | No | Required | Analytics and App functionality |
| App activity › App interactions (on the Data types screen) | Yes | Yes | No | Required | Analytics and App functionality |
Every other data type in the form: **No**. Encrypted in transit: **Yes**. Deletion available: **Yes**.
**Paste this into the form's free-text disclosure field:**
> 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.
**Add this sentence to the privacy policy you already publish:**
> 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.
For the permissions part of the same form, these are the Android permissions this adds — the second one only if you read the Install Referrer, and it comes from Google's own library:
```xml
```
Why *Shared* is Yes: 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 — so the exemption does not apply and the honest answer is Yes. A more conservative reading also ticks *Personal info → User IDs*; it is not needed, because the reporter never receives an account, an e-mail or a name, only a random token and a random install id, but adding it costs nothing and contradicts nothing above.
## Step 6 — Ship a closed-track build — needs the human
Bump `versionCode`, build the release bundle, upload it to the **closed testing** track in Play Console and roll it out. Nothing pairs until an install arrives through a developerpact link, so the build is safe to release to the track at once.
Testers who already have the app must **update to this build from the Play Store** and then tap *Pair this phone* on their tester page — Play never delivers a referrer to an app that is already installed, and a build without the pairing entry point cannot pair at all.
## Step 7 — Verify from your own phone — the only check, needs the human
There is one check, and it is not a log line: **the first heartbeat from your own install.**
1. Sign in at [https://developerpact.com/apps](https://developerpact.com/apps) and open the app's ledger page.
2. Press **Start self-test**. It opens a self-test card with your own install link. (The self-test is not counted as a tester.)
3. On your Android phone, signed into the Play Store with an account that is on the app's closed-test list: opt in to the test, open the install link from the card and install the closed-track build from the store page it opens. If the app is already installed, update it in the Play Store first, then tap **Pair this phone** on the card.
4. Open the app and keep it in the foreground for at least 60 seconds.
The self-test card turns green on the first heartbeat and the app is marked **SDK verified**. Only a verified app can take testers; until then the tester page refuses to open seats. You are also, at that point, looking at exactly what your testers will see.
Paired but nothing appears? The usual causes, in order:
- The install was not a Play install. Both installer fields must be `com.android.vending`; a build installed by hand pairs and reports but never earns an active day. That is the rule, not a bug.
- `fgSeconds` never reached `thresholdSeconds` (60 s by default).
- `day` was formatted in UTC and landed on the wrong date. The ledger shows which of the three it was.
## Transport, headers and error style
| | |
| --- | --- |
| 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 ` 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 Flutter 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. |
## What this adds to your app
- `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`. Skipping source A skips both.
- One exported activity of your own — the block in step 1 — reachable only at `devpact:///pair`. Keep it invisible and finished before it is drawn.
- 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. **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.
## Removing it after production
Once Google grants production access: delete your pairing activity and its `` 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. Then take the two Data safety rows and the privacy-policy sentence back out.
Keeping it for the next closed-testing round is fine, and the declaration stays honest as written: an install that was never paired never touches the network, and a paired install stops the moment its testing ends.
## Done when
- [ ] On first launch the Install Referrer is read once; a `developerpact` referrer pairs, anything else writes `referrerChecked` and the referrer is never read again. The flag is written only when there is no token or when the server has answered.
- [ ] An exported, invisible activity handles `devpact:///pair?t=`: it stores the token, pairs, brings the launcher activity forward and finishes.
- [ ] `installId` (UUID v4) and `installSecret` live in app-private storage; the secret is never cleared on a 401.
- [ ] Foreground seconds are counted from a monotonic clock, per local calendar day of the session's start, capped, and checkpointed to storage every 20 s.
- [ ] Heartbeats are sent at foreground start (every unsent day, up to 7) and the first time today crosses `thresholdSeconds`; nothing runs in the background or on a timer.
- [ ] Every status in the two tables is handled as the table says; `410 {stop:true}` stops for good, `5xx` or no answer changes nothing.
- [ ] The Android build compiles with only `INTERNET` added (plus the referrer permission from Google's library if source A is used).
- [ ] Human: the two Data safety rows are declared and the disclosure text is pasted.
- [ ] Human: the privacy-policy sentence is published.
- [ ] Human: a closed-track build with the client is live.
- [ ] Human: the self-test card on the app's ledger is green and the app shows as SDK verified.
## Where everything else is
- The integration page, with the same steps and copy buttons: [https://developerpact.com/sdk](https://developerpact.com/sdk?path=rest)
- The package on pub.dev: [https://pub.dev/packages/developerpact](https://pub.dev/packages/developerpact)
- The reporter's source, a public repository that holds the package and nothing else: [https://github.com/cns-studio/developerpact](https://github.com/cns-studio/developerpact)
- What a tester can and cannot fake, the support policy, and the trust artifacts (line counts, a reproducible archive hash, the merged permissions): [https://developerpact.com/sdk#trust](https://developerpact.com/sdk#trust)
- Privacy policy and terms: [https://developerpact.com/privacy](https://developerpact.com/privacy), [https://developerpact.com/terms](https://developerpact.com/terms)