REST API

Two endpoints, both POST, both JSON, both strict. This is the whole protocol between an install and developerpact.com.

  1. Overview
  2. POST /pair
  3. POST /heartbeat
  4. Rejections
  5. Referrer format
  6. Deep-link format

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 /pair returned. Bearer, not HMAC: TLS provides integrity, cumulative-max makes a replay harmless, and a secret can be stored hashed.
Response headers
cache-control: no-store on every response. The 200 from /pair carries 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

StatusBodyMeaningThe 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

StatusBodyMeaningThe 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.

CodeWhenThe value
clock_skewsentAt is more than 10 minutes from the server's clock.still counted — annotation only
over_capfgSeconds above the 8-hour day cap.not counted
late_duplicateA past day (older than yesterday) that this install already reported, or yesterday revised by more than the elapsed time allows.not counted
impossible_deltaSame-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.