REST API
Two endpoints, both POST, both JSON, both strict. This is the whole protocol between an install and developerpact.com.
Overview
- 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. The JSON schemas below are generated from the server’s own validators.
- Authentication
- None on
/pair— the token is the credential.Authorization: Bearer <installSecret>on/heartbeat, the 43-character secret/pairreturned. Bearer, not HMAC: TLS provides integrity, cumulative-max makes a replay harmless, and a secret can be stored hashed. - Response headers
cache-control: no-storeon every response. The 200 from/paircarries a secret; nothing here may be cached by anyone.- Errors
- Fixed strings, so a probe learns nothing from the text: an unknown token and a token for another package get the same 404.
- Transport
- https only. The SDK uses HttpURLConnection with a 5-second timeout and no retry beyond "try again on the next launch".
POST /pair
Binds an install to a testing. One transaction on the server: the token’s use count, the testing’s install list and the install document must agree, so two devices racing on one token cannot both pass. The same installId pairing again is a re-pair (the device lost its secret, or the kill switch fired and a new deep link revived it): it gets a fresh secret and spends no token use. A different installId on the same token is a reinstall — allowed twice, flagged beyond that.
Request body
{
"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
}JSON Schema (generated)
{
"$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 SDK generates once per install and keeps in SharedPreferences."
},
"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": "SDK identifier, \"flutter/<version>\". The server may retire versions below a minimum (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
}Responses
| Status | Body | Meaning | The SDK |
|---|---|---|---|
| 200 | { installSecret, serverDay, rules: { thresholdSeconds } } | Paired. The secret is returned exactly once and stored on the server only as SHA-256. | stores the secret, starts 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. | clears the pending token, stays silent |
| 410 | { stop: true } | The testing is over, or the SDK version in the body is below the minimum the server accepts — the kill switch. The token is left unspent. | sets stopped; silent until a new deep-link token (update the app first) |
| 410 | { stop: false, reason: "token_expired" } | The token expired (30 days), was rotated away, or was spent (2 installs). Not the kill switch. | reports "not paired"; the tester page shows a fresh token |
| 422 | { ok: false, error: "malformed body" } | The body did not match the strict schema — an unknown field is a 422, not a shrug. | logs once, drops the request |
POST /heartbeat
One local day’s cumulative foreground time from one install. Upserted with max, never summed. day is the device’s today (with tzOffsetMin): today is always accepted; a past day within 7 days is accepted once (offline catch-up) and refused as late_duplicate after that; anything older is out of window. A same-day increase larger than the time that has passed is impossible_delta. Day cap 8 h. The response’s serverDay lets the SDK check its own calendar.
Request
POST https://developerpact.com/api/sdk/v1/heartbeat content-type: application/json authorization: Bearer <installSecret>
{
"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"
}JSON Schema (generated)
{
"$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 SDK generates once per install and keeps in SharedPreferences."
},
"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": "SDK identifier, \"flutter/<version>\". The server may retire versions below a minimum (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
}Responses
| Status | Body | Meaning | The SDK |
|---|---|---|---|
| 200 | { ok: true, activeToday, serverDay } | Recorded (or recorded as rejected — see below; the status is 200 either way). | marks the day sent; checks serverDay against its own day |
| 401 | { ok: false, error: "unauthorized" } | Bearer missing, malformed, or not this install's secret. | leaves its state alone |
| 404 | { ok: false, error: "not found" } | Unknown installId, or the package does not match the install. | clears the pairing |
| 410 | { stop: true } | The install is stopped (its testing ended, or the SDK version was retired). | silent until re-paired |
| 422 | { ok: false, error: "malformed body" } | Strict schema. | logs once, drops the request |
| 422 | { ok: false, error: "day out of window", serverDay } | day is outside [serverDay − 7, serverDay + 1]. | drops that bucket, resyncs its day |
| 429 | { ok: false, error: "rate limited" } | More than 120 heartbeats an hour from one installId, counted before any database read. | waits for the next launch |
Rejections recorded on a 200
A heartbeat that fails a rule still gets a 200: the day document records the rejection in rejected[], the counters move, and the value is or is not folded in. The SDK does not need to know; the ledger and the drop mail show it. A skewed clock (more than 10 minutes) is recorded but still counted, because the day window already bounds what a wrong clock can claim.
| 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 |
Referrer format
The tester’s install link (/j/<token>) sends the phone to Play with a referrer. Play hands it to the app at install time; the SDK reads it once, on first launch, through the Install Referrer API, and only acts when utm_source is developerpact and utm_content is a well-formed token. Anything else — organic, (not set), FEATURE_NOT_SUPPORTED, SERVICE_UNAVAILABLE, a timeout — writes a flag and never touches the network on that install.
- decoded
utm_source=developerpact&utm_content=Qm9yaW5nVG9rZW4xMjM0NQ- in the URL
https://play.google.com/store/apps/details?id=com.example.myapp&referrer=utm_source%3Ddeveloperpact%26utm_content%3DQm9yaW5nVG9rZW4xMjM0NQ- token
- 22 characters of base64url (16 random bytes), e.g.
Qm9yaW5nVG9rZW4xMjM0NQ. A still-encoded value (%3D,%26) is decoded first, because some paths double-encode.
The utm_ shape is deliberate: a host app’s own analytics (Firebase, AppsFlyer, Branch, Adjust) sees a named source instead of garbage, and multiple readers are fine — reading the referrer does not consume it. A referrer is not proof of a Play install; the installer check is.
Deep-link format
For an app that is already installed, the tester page opens the plugin’s PairActivity with an Android intent URL. It must be a real tap: Chrome refuses custom schemes triggered from a timer or script. If the installed build predates the SDK there is no PairActivity, and the browser falls back to the tester page with ?state=needs-update.
- what the activity receives
devpact://com.example.myapp/pair?t=Qm9yaW5nVG9rZW4xMjM0NQ- what the page opens
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- scheme / host
devpact/ the app’s applicationId, from the merged<activity>block on the integration page- path / query
/pair,t=<token>; the token is format-checked before it is stored- pairedVia
deeplinkin the resulting/paircall; the referrer path sendsreferrer
The activity stores the token, calls the native pair function directly — no waiting for Dart’s start(), so an app alive only in the background pairs too — brings the launcher activity forward and finishes. A token for another testing, or for a testing this install never belonged to, gets a 404 and changes nothing on the device.