API

Every endpoint below is authenticated with a project API token (generate one on a project's Collaborators page) via Authorization: Bearer <token>. A token is scoped to exactly one project.

An org admin can instead generate an organization token (on an org's Settings page) — it uses the same Authorization: Bearer <token> header, but is always read-only and has no project of its own, so every /api/v1/* request made with one must name a project in that org via a ?projectId= query parameter. /api/ci/releases/create (publishing) does not accept organization tokens at all — publishing always requires a project token.

POST/api/ci/releases/create

Publish a new release — the same endpoint CI pipelines use.

curl -X POST https://your-app/api/ci/releases/create \
  -H "Authorization: Bearer qap_..." \
  -F platform=ios -F version=1.2.0 -F bundleId=com.company.app \
  -F channel=beta -F file=@app.ipa
GET/api/v1/releases

List the project's published releases, newest first (?limit=1-100, default 20).

curl https://your-app/api/v1/releases \
  -H "Authorization: Bearer qap_..."
GET/api/v1/releases/:id

Fetch a single release's detail.

curl https://your-app/api/v1/releases/<release-id> \
  -H "Authorization: Bearer qap_..."
GET/api/v1/check-update

Ask whether a newer build exists for a platform+channel than the one the caller is currently running. Params: platform (ios|android|web, required), currentVersion (required), currentBuildNumber (optional, tiebreaker), channel (optional, default production), deviceId (optional — a stable per-device identifier; if a release has a staged rollout percentage set, a device is bucketed by this id and only sees the new build once it falls within that percentage; if a release is scoped to 'registered devices only' (iOS), deviceId must match a UDID in the project's registered devices for the device to see it — send the real device UDID as deviceId if you use that mode; either way, a device that doesn't qualify keeps getting the previous published release; omit deviceId to always get the latest, ignoring both). Returns { updateAvailable: false } if nothing newer, or { updateAvailable: true, latestVersion, latestBuildNumber, notes, updateUrl } — updateUrl is an itms-services:// link for iOS, a direct APK download for Android, or the app's own URL for web.

curl "https://your-app/api/v1/check-update?platform=ios&currentVersion=1.2.0" \
  -H "Authorization: Bearer qap_..."
POST/api/public/crash-report

Report a caught exception from a distributed app. No auth token — a release id is unguessable, same trust model as the tester feedback endpoint. Required: releaseId, exceptionType. Optional: message, stackTrace, deviceModel, osVersion, deviceName, osName (falls back to a User-Agent parse when omitted, same as report-issue), networkType (wifi|cellular|none), wifiSignalStrength (RSSI in dBm — Android-only; iOS has no public API for real signal strength, Apple removed it, so this is always omitted there and on web). Reports are grouped by exceptionType + the stack trace's first line — no automatic symbolication, so send a human-readable stack (e.g. a JS Error.stack, or your own de-obfuscated trace) rather than raw addresses if you want it to mean anything on the Crashes tab. The Capacitor plugin's enableAutoCrashDetection() (and the 'auto-capturing crashes' sections in sdk/native-examples) wires window.onerror/unhandledrejection or a Thread default handler so crashes reach this endpoint automatically instead of being wrapped by hand.

fetch("https://your-app/api/public/crash-report", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    releaseId: "<release-id>",
    exceptionType: error.name,
    message: error.message,
    stackTrace: error.stack,
    osName: Platform.OS,
    osVersion: Platform.Version?.toString(),
    // networkType/wifiSignalStrength: auto-filled by the Capacitor plugin
    // and native SDK snippets from platform APIs — a bare fetch() like this
    // would need e.g. @react-native-community/netinfo to supply networkType.
  }),
});
POST/api/public/report-issue

Send tester feedback from a distributed app — the in-app equivalent of the web share page's 'Report an issue' form, called by the SDK's sendFeedback(). Files straight onto the project's board. No auth token — a release id is unguessable, same trust model as crash-report. Required: releaseId, feedback. Optional: reporterName, reporterEmail, deviceModel, osName, osVersion — when omitted, device/OS are parsed from the request's User-Agent instead (meaningful for a web caller, not a native one).

fetch("https://your-app/api/public/report-issue", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    releaseId: "<release-id>",
    feedback: "Login button does nothing on iPad",
    reporterName: "Jane Doe",
    reporterEmail: "jane@example.com",
    osName: Platform.OS,
    osVersion: Platform.Version?.toString(),
  }),
});
POST/api/public/install-receipt

Confirm an install from a distributed app — called by the SDK's confirmInstall() on first launch after a successful install. This is what lets analytics distinguish confirmed installs from install clicks (clicks are counted on the manifest/download redirect and can be inflated by aborted downloads; a receipt proves the app actually ran). No auth token — a release id is unguessable, same trust model as crash-report. Required: releaseId. Optional: platform-derived deviceModel, osName, osVersion, appVersion, firstLaunchAt (the app's own launch time), deviceId (a stable per-device id, used only for rate limiting). Best-effort and fire-and-forget — the app should never block startup on this.

fetch("https://your-app/api/public/install-receipt", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    releaseId: "<release-id>",
    deviceModel: Device.model,
    osVersion: Platform.Version?.toString(),
    appVersion: appVersion,
  }),
});
POST/api/public/register-push-token

Register a device's FCM token so it receives a native push notification the moment a new release publishes on its channel — called by the SDK's registerPushToken(). No auth token — a release id is unguessable, same trust model as crash-report/install-receipt. Required: releaseId, deviceId, platform (ios or android), token. Optional: channel (defaults to the release's own channel). This endpoint only relays a token already obtained from Firebase's own SDK — it doesn't request notification permission or fetch a token itself. Fire-and-forget; call it once you have a token, and again whenever Firebase reissues one.

fetch("https://your-app/api/public/register-push-token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    releaseId: "<release-id>",
    deviceId: deviceId,
    platform: Platform.OS,
    token: fcmToken,
  }),
});

In-app update checks

To show a "New version available" prompt inside a distributed app: call check-update on launch, and if updateAvailable is true, confirm with the user and open updateUrl — that's the whole flow, no SDK needed. On iOS it triggers the native OTA install prompt; on Android it downloads the APK and the OS offers to install it.

// React Native, called once on app launch
const res = await fetch(
  `https://your-app/api/v1/check-update?platform=${Platform.OS}&currentVersion=${appVersion}`,
  { headers: { Authorization: "Bearer qap_..." } }
);
const data = await res.json();

if (data.updateAvailable) {
  Alert.alert(
    "Update available",
    `Version ${data.latestVersion} is ready. Update now?`,
    [
      { text: "Later", style: "cancel" },
      { text: "Yes", onPress: () => Linking.openURL(data.updateUrl) },
    ]
  );
}

Client SDK

Both calls above are plain HTTP — no SDK is required for either. If you'd rather not wire up the requests yourself, this repo's sdk/ directory has a starting point for common stacks:

  • Capacitor(React, Vue, Ionic, or any Vite build wrapped for native)@qa-platform/capacitor-plugin— a real plugin with web, Android (Kotlin), and iOS (Swift) implementations. See sdk/capacitor-plugin.
Android (Kotlin)kotlin
# Android (Kotlin) — no plugin required

Both calls are plain HTTP. This uses OkHttp (already a transitive
dependency of most Android projects); swap in Retrofit/Ktor if that's
what your app already uses.

```kotlin
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.provider.Settings
import okhttp3.*
import org.json.JSONObject
import java.io.IOException

// `context` (an application Context) is only needed for reportCrash's
// device name / network type / wifi signal strength lookups below — pass
// `applicationContext`, not an Activity, to avoid leaking it.
class QaPlatformClient(private val context: Context, private val baseUrl: String, private val token: String) {
    private val client = OkHttpClient()

    private fun currentDeviceName(): String =
        Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) ?: android.os.Build.MODEL

    private fun currentNetworkType(): String {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none"
        val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return "none"
        return when {
            caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
            caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
            else -> "none"
        }
    }

    // No location permission needed — RSSI itself isn't location-gated
    // (only SSID/BSSID are). Requires ACCESS_NETWORK_STATE + ACCESS_WIFI_STATE
    // (both "normal", no runtime prompt) in your app's AndroidManifest.xml.
    private fun currentWifiSignalStrength(): Int? {
        val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return null
        val rssi = wifiManager.connectionInfo?.rssi ?: return null
        return if (rssi == WifiInfo.INVALID_RSSI) null else rssi
    }

    fun checkForUpdate(currentVersion: String, onResult: (JSONObject) -> Unit, onError: (Exception) -> Unit) {
        val url = "$baseUrl/api/v1/check-update?platform=android&currentVersion=$currentVersion"
        val request = Request.Builder().url(url).header("Authorization", "Bearer $token").build()

        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) = onError(e)
            override fun onResponse(call: Call, response: Response) {
                response.use {
                    if (!it.isSuccessful) { onError(IOException("HTTP ${it.code}")); return }
                    onResult(JSONObject(it.body?.string().orEmpty()))
                }
            }
        })
    }

    fun reportCrash(releaseId: String, throwable: Throwable) {
        val body = JSONObject().apply {
            put("releaseId", releaseId)
            put("exceptionType", throwable.javaClass.simpleName)
            put("message", throwable.message ?: "")
            put("stackTrace", throwable.stackTraceToString())
            put("deviceModel", android.os.Build.MODEL)
            put("osVersion", android.os.Build.VERSION.RELEASE)
            put("deviceName", currentDeviceName())
            put("osName", "Android")
            put("networkType", currentNetworkType())
            currentWifiSignalStrength()?.let { put("wifiSignalStrength", it) }
        }
        val request = Request.Builder()
            .url("$baseUrl/api/public/crash-report")
            .post(RequestBody.create("application/json".toMediaType(), body.toString()))
            .build()
        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {} // best-effort, see below
            override fun onResponse(call: Call, response: Response) { response.close() }
        })
    }
}
```

Add `android.permission.ACCESS_NETWORK_STATE` and
`android.permission.ACCESS_WIFI_STATE` to your `AndroidManifest.xml` for
the `networkType`/`wifiSignalStrength` lookups above — both are "normal"
protection-level permissions (no runtime prompt).

## Auto-capturing crashes

Install a global uncaught-exception handler once, e.g. in your
`Application.onCreate()`, and chain to the platform default so the crash
still terminates the process normally:

```kotlin
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
    qaPlatformClient.reportCrash(BuildConfig.QA_PLATFORM_RELEASE_ID, throwable)
    defaultHandler?.uncaughtException(thread, throwable)
}
```

If your app's logic lives in a WebView (Capacitor/Cordova/React Native), most
crashes are JS exceptions, which never reach the Thread handler — wire
`window.onerror`/`unhandledrejection` in the WebView instead and relay them
to the same `reportCrash`. The Capacitor plugin's
`enableAutoCrashDetection()` does exactly this for you in one call.

`BuildConfig.QA_PLATFORM_RELEASE_ID` — inject the release id your CI got
back from `/api/ci/releases/create` as a build config field, so each build
reports against its own release.

## Sending feedback

The in-app equivalent of the web share page's "Report an issue" form.

```kotlin
class QaPlatformClient {
    fun sendFeedback(releaseId: String, feedback: String, reporterName: String? = null, reporterEmail: String? = null) {
        val body = JSONObject().apply {
            put("releaseId", releaseId)
            put("feedback", feedback)
            reporterName?.let { put("reporterName", it) }
            reporterEmail?.let { put("reporterEmail", it) }
            put("deviceModel", android.os.Build.MODEL)
            put("osName", "Android")
            put("osVersion", android.os.Build.VERSION.RELEASE)
        }
        val request = Request.Builder()
            .url("$baseUrl/api/public/report-issue")
            .post(RequestBody.create("application/json".toMediaType(), body.toString()))
            .build()
        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {} // best-effort
            override fun onResponse(call: Call, response: Response) { response.close() }
        })
    }
}
```

## Confirming an install

Call `confirmInstall(BuildConfig.QA_PLATFORM_RELEASE_ID)` once on first
launch after install — this is what lets qa-platform report "confirmed
installs" (rather than just install clicks) to your project's analytics.
Best-effort and fire-and-forget; it never blocks your app's startup.

```kotlin
class QaPlatformClient {
    fun confirmInstall(releaseId: String, deviceId: String? = null) {
        val body = JSONObject().apply {
            put("releaseId", releaseId)
            put("deviceModel", android.os.Build.MODEL)
            put("osVersion", android.os.Build.VERSION.RELEASE)
            put("appVersion", BuildConfig.VERSION_NAME)
            deviceId?.let { put("deviceId", it) }
        }
        val request = Request.Builder()
            .url("$baseUrl/api/public/install-receipt")
            .post(RequestBody.create("application/json".toMediaType(), body.toString()))
            .build()
        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {} // best-effort
            override fun onResponse(call: Call, response: Response) { response.close() }
        })
    }
}
```

## Receiving push notifications

Register your device's FCM token so qa-platform can push you the moment a
new release publishes on your channel — instead of only finding out next
time you call `checkForUpdate`. This snippet only relays a token you've
already obtained from the Firebase Android SDK
(`com.google.firebase:firebase-messaging`, a peer dependency this snippet
doesn't set up) — call it once you have a token, and again from your
`FirebaseMessagingService.onNewToken()` override whenever Firebase
reissues one:

```kotlin
class QaPlatformClient {
    fun registerPushToken(releaseId: String, deviceId: String, token: String) {
        val body = JSONObject().apply {
            put("releaseId", releaseId)
            put("deviceId", deviceId)
            put("token", token)
            put("platform", "android")
        }
        val request = Request.Builder()
            .url("$baseUrl/api/public/register-push-token")
            .post(RequestBody.create("application/json".toMediaType(), body.toString()))
            .build()
        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {} // best-effort
            override fun onResponse(call: Call, response: Response) { response.close() }
        })
    }
}
```

```kotlin
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
    qaPlatformClient.registerPushToken(BuildConfig.QA_PLATFORM_RELEASE_ID, deviceId, token)
}
```
Android (Java)java
# Android (Java) — no plugin required

Same two endpoints as the [Kotlin example](android-kotlin.md), via OkHttp.

```java
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkCapabilities;
import android.net.Network;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.provider.Settings;
import okhttp3.*;
import org.json.JSONObject;
import java.io.IOException;

public class QaPlatformClient {
    // Only needed for reportCrash's device name / network type / wifi
    // signal strength lookups below — pass applicationContext, not an
    // Activity, to avoid leaking it.
    private final Context context;
    private final String baseUrl;
    private final String token;
    private final OkHttpClient client = new OkHttpClient();

    public QaPlatformClient(Context context, String baseUrl, String token) {
        this.context = context;
        this.baseUrl = baseUrl;
        this.token = token;
    }

    private String currentDeviceName() {
        String name = Settings.Global.getString(context.getContentResolver(), Settings.Global.DEVICE_NAME);
        return name != null ? name : android.os.Build.MODEL;
    }

    private String currentNetworkType() {
        ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
        if (cm == null) return "none";
        Network network = cm.getActiveNetwork();
        NetworkCapabilities caps = network != null ? cm.getNetworkCapabilities(network) : null;
        if (caps == null) return "none";
        if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return "wifi";
        if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) return "cellular";
        return "none";
    }

    // No location permission needed — RSSI itself isn't location-gated
    // (only SSID/BSSID are). Requires ACCESS_NETWORK_STATE + ACCESS_WIFI_STATE
    // (both "normal", no runtime prompt) in your app's AndroidManifest.xml.
    private Integer currentWifiSignalStrength() {
        WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
        if (wifiManager == null || wifiManager.getConnectionInfo() == null) return null;
        int rssi = wifiManager.getConnectionInfo().getRssi();
        return rssi == WifiInfo.INVALID_RSSI ? null : rssi;
    }

    public interface ResultCallback {
        void onResult(JSONObject json);
        void onError(Exception e);
    }

    public void checkForUpdate(String currentVersion, ResultCallback callback) {
        String url = baseUrl + "/api/v1/check-update?platform=android&currentVersion=" + currentVersion;
        Request request = new Request.Builder()
                .url(url)
                .header("Authorization", "Bearer " + token)
                .build();

        client.newCall(request).enqueue(new Callback() {
            @Override public void onFailure(Call call, IOException e) {
                callback.onError(e);
            }

            @Override public void onResponse(Call call, Response response) throws IOException {
                try (Response r = response) {
                    if (!r.isSuccessful()) {
                        callback.onError(new IOException("HTTP " + r.code()));
                        return;
                    }
                    callback.onResult(new JSONObject(r.body().string()));
                } catch (Exception e) {
                    callback.onError(e);
                }
            }
        });
    }

    public void reportCrash(String releaseId, Throwable throwable) {
        try {
            JSONObject body = new JSONObject();
            body.put("releaseId", releaseId);
            body.put("exceptionType", throwable.getClass().getSimpleName());
            body.put("message", throwable.getMessage());
            body.put("stackTrace", android.util.Log.getStackTraceString(throwable));
            body.put("deviceModel", android.os.Build.MODEL);
            body.put("osVersion", android.os.Build.VERSION.RELEASE);
            body.put("deviceName", currentDeviceName());
            body.put("osName", "Android");
            body.put("networkType", currentNetworkType());
            Integer wifiSignal = currentWifiSignalStrength();
            if (wifiSignal != null) body.put("wifiSignalStrength", wifiSignal);

            RequestBody requestBody = RequestBody.create(
                    body.toString(), MediaType.parse("application/json"));
            Request request = new Request.Builder()
                    .url(baseUrl + "/api/public/crash-report")
                    .post(requestBody)
                    .build();

            client.newCall(request).enqueue(new Callback() {
                @Override public void onFailure(Call call, IOException e) { /* best-effort */ }
                @Override public void onResponse(Call call, Response response) { response.close(); }
            });
        } catch (Exception ignored) {
            // best-effort — never let crash reporting itself throw
        }
    }
}
```

Add `android.permission.ACCESS_NETWORK_STATE` and
`android.permission.ACCESS_WIFI_STATE` to your `AndroidManifest.xml` for
the `networkType`/`wifiSignalStrength` lookups above — both are "normal"
protection-level permissions (no runtime prompt).

## Auto-capturing crashes

```java
Thread.UncaughtExceptionHandler defaultHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
    qaPlatformClient.reportCrash(BuildConfig.QA_PLATFORM_RELEASE_ID, throwable);
    if (defaultHandler != null) defaultHandler.uncaughtException(thread, throwable);
});
```

## Sending feedback

The in-app equivalent of the web share page's "Report an issue" form.

```java
public void sendFeedback(String releaseId, String feedback, String reporterName, String reporterEmail) {
    try {
        JSONObject body = new JSONObject();
        body.put("releaseId", releaseId);
        body.put("feedback", feedback);
        if (reporterName != null) body.put("reporterName", reporterName);
        if (reporterEmail != null) body.put("reporterEmail", reporterEmail);
        body.put("deviceModel", android.os.Build.MODEL);
        body.put("osName", "Android");
        body.put("osVersion", android.os.Build.VERSION.RELEASE);

        RequestBody requestBody = RequestBody.create(
                body.toString(), MediaType.parse("application/json"));
        Request request = new Request.Builder()
                .url(baseUrl + "/api/public/report-issue")
                .post(requestBody)
                .build();

        client.newCall(request).enqueue(new Callback() {
            @Override public void onFailure(Call call, IOException e) { /* best-effort */ }
            @Override public void onResponse(Call call, Response response) { response.close(); }
        });
    } catch (Exception ignored) {
        // best-effort — never let feedback submission itself throw
    }
}
```

## Confirming an install

Call `confirmInstall(...)` once on first launch after install — this is what
lets qa-platform report "confirmed installs" (rather than just install
clicks) to your project's analytics. Best-effort and fire-and-forget; it
never blocks your app's startup.

```java
public void confirmInstall(String releaseId, String deviceId) {
    try {
        JSONObject body = new JSONObject();
        body.put("releaseId", releaseId);
        body.put("deviceModel", android.os.Build.MODEL);
        body.put("osVersion", android.os.Build.VERSION.RELEASE);
        body.put("appVersion", BuildConfig.VERSION_NAME);
        if (deviceId != null) body.put("deviceId", deviceId);

        RequestBody requestBody = RequestBody.create(
                body.toString(), MediaType.parse("application/json"));
        Request request = new Request.Builder()
                .url(baseUrl + "/api/public/install-receipt")
                .post(requestBody)
                .build();

        client.newCall(request).enqueue(new Callback() {
            @Override public void onFailure(Call call, IOException e) { /* best-effort */ }
            @Override public void onResponse(Call call, Response response) { response.close(); }
        });
    } catch (Exception ignored) {
        // best-effort — never let install confirmation itself throw
    }
}
```

## Receiving push notifications

Register your device's FCM token so qa-platform can push you the moment a
new release publishes on your channel — instead of only finding out next
time you call `checkForUpdate`. This snippet only relays a token you've
already obtained from the Firebase Android SDK
(`com.google.firebase:firebase-messaging`, a peer dependency this snippet
doesn't set up) — call it once you have a token, and again from your
`FirebaseMessagingService.onNewToken()` override whenever Firebase
reissues one:

```java
public void registerPushToken(String releaseId, String deviceId, String token) {
    try {
        JSONObject body = new JSONObject();
        body.put("releaseId", releaseId);
        body.put("deviceId", deviceId);
        body.put("token", token);
        body.put("platform", "android");

        RequestBody requestBody = RequestBody.create(
                body.toString(), MediaType.parse("application/json"));
        Request request = new Request.Builder()
                .url(baseUrl + "/api/public/register-push-token")
                .post(requestBody)
                .build();

        client.newCall(request).enqueue(new Callback() {
            @Override public void onFailure(Call call, IOException e) { /* best-effort */ }
            @Override public void onResponse(Call call, Response response) { response.close(); }
        });
    } catch (Exception ignored) {
        // best-effort — never let token registration itself throw
    }
}
```

```java
FirebaseMessaging.getInstance().getToken().addOnSuccessListener(token -> {
    qaPlatformClient.registerPushToken(BuildConfig.QA_PLATFORM_RELEASE_ID, deviceId, token);
});
```
iOS (Swift)swift
# iOS (Swift) — no plugin required

Plain `URLSession` calls to the same two endpoints.

```swift
import Foundation
import Network
import UIKit

final class QaPlatformClient {
    let baseUrl: String
    let token: String

    // No public iOS API reports wifi signal strength (Apple removed
    // CTTelephonyNetworkInfo-based RSSI access years ago) — this monitor
    // only tells us wifi vs. cellular vs. none, cached from the last path
    // update so reportCrash can read it synchronously.
    private let pathMonitor = NWPathMonitor()
    private var latestNetworkType: String = "none"

    init(baseUrl: String, token: String) {
        self.baseUrl = baseUrl
        self.token = token
        pathMonitor.pathUpdateHandler = { [weak self] path in
            guard let self = self else { return }
            if path.usesInterfaceType(.wifi) {
                self.latestNetworkType = "wifi"
            } else if path.usesInterfaceType(.cellular) {
                self.latestNetworkType = "cellular"
            } else {
                self.latestNetworkType = "none"
            }
        }
        pathMonitor.start(queue: DispatchQueue(label: "com.qaplatform.pathmonitor"))
    }

    func checkForUpdate(currentVersion: String, completion: @escaping (Result<[String: Any], Error>) -> Void) {
        var components = URLComponents(string: "\(baseUrl)/api/v1/check-update")!
        components.queryItems = [
            URLQueryItem(name: "platform", value: "ios"),
            URLQueryItem(name: "currentVersion", value: currentVersion)
        ]
        var request = URLRequest(url: components.url!)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error { completion(.failure(error)); return }
            guard let data = data,
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
                completion(.failure(NSError(domain: "QaPlatform", code: -1)))
                return
            }
            completion(.success(json))
        }.resume()
    }

    func reportCrash(releaseId: String, exceptionType: String, message: String?, stackTrace: String?) {
        var body: [String: Any] = [
            "releaseId": releaseId,
            "exceptionType": exceptionType,
            "deviceModel": UIDevice.current.model,
            "osVersion": UIDevice.current.systemVersion,
            // Since iOS 16, UIDevice.current.name returns a generic,
            // non-identifying string (e.g. "iPhone") unless the app holds
            // Apple's user-assigned-device-name entitlement.
            "deviceName": UIDevice.current.name,
            "osName": "iOS",
            "networkType": latestNetworkType
            // wifiSignalStrength intentionally omitted — no public iOS API
            // for real RSSI/signal strength.
        ]
        body["message"] = message
        body["stackTrace"] = stackTrace

        var request = URLRequest(url: URL(string: "\(baseUrl)/api/public/crash-report")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)

        URLSession.shared.dataTask(with: request).resume() // best-effort, fire and forget
    }
}
```

## Auto-capturing crashes

Objective-C-level uncaught exceptions can be captured with
`NSSetUncaughtExceptionHandler`; Swift traps (force-unwrap, array
out-of-bounds) terminate the process without going through it, so this
only catches Obj-C-style `NSException`s (e.g. from Cocoa APIs):

```swift
NSSetUncaughtExceptionHandler { exception in
    qaPlatformClient.reportCrash(
        releaseId: releaseId,
        exceptionType: exception.name.rawValue,
        message: exception.reason,
        stackTrace: exception.callStackSymbols.joined(separator: "\n")
    )
}
```

For genuine Swift-runtime traps you need a signal handler
(`SIGTRAP`/`SIGILL`/etc.) or a third-party crash library that captures a
native stack — out of scope for this MVP endpoint, which has no
symbolication (see `main/pages/api/public/crash-report.js`).

If your app's logic lives in a WebView (Capacitor/Cordova/React Native),
most crashes are JS exceptions, which never reach the Obj-C handler — wire
`window.onerror`/`unhandledrejection` in the WebView instead and relay them
to the same `reportCrash`. The Capacitor plugin's
`enableAutoCrashDetection()` does exactly this for you in one call.

## Sending feedback

The in-app equivalent of the web share page's "Report an issue" form —
files straight onto the project's board. `deviceModel`/`osVersion` are
filled in from the device the same way `reportCrash` does.

```swift
func sendFeedback(releaseId: String, feedback: String, reporterName: String?, reporterEmail: String?) {
    var body: [String: Any] = [
        "releaseId": releaseId,
        "feedback": feedback,
        "deviceModel": UIDevice.current.model,
        "osName": "iOS",
        "osVersion": UIDevice.current.systemVersion
    ]
    body["reporterName"] = reporterName
    body["reporterEmail"] = reporterEmail

    var request = URLRequest(url: URL(string: "\(baseUrl)/api/public/report-issue")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONSerialization.data(withJSONObject: body)

    URLSession.shared.dataTask(with: request).resume() // best-effort, fire and forget
}
```

## Confirming an install

Call `confirmInstall(...)` once on first launch after install — this is what
lets qa-platform report "confirmed installs" (rather than just install
clicks) to your project's analytics. Best-effort and fire-and-forget; it
never blocks your app's startup.

```swift
func confirmInstall(releaseId: String, deviceId: String?) {
    var body: [String: Any] = [
        "releaseId": releaseId,
        "deviceModel": UIDevice.current.model,
        "osVersion": UIDevice.current.systemVersion,
        "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
    ]
    if let deviceId { body["deviceId"] = deviceId }

    var request = URLRequest(url: URL(string: "\(baseUrl)/api/public/install-receipt")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONSerialization.data(withJSONObject: body)

    URLSession.shared.dataTask(with: request).resume() // best-effort, fire and forget
}
```

## Receiving push notifications

Register your device's FCM token so qa-platform can push you the moment a
new release publishes on your channel — instead of only finding out next
time you call `checkForUpdate`. This snippet only relays a token you've
already obtained from the Firebase iOS SDK (`Messaging`, a peer
dependency this snippet doesn't set up) — call it once you have a token,
and again from your `MessagingDelegate`'s
`messaging(_:didReceiveRegistrationToken:)` whenever Firebase reissues one:

```swift
func registerPushToken(releaseId: String, deviceId: String, token: String) {
    let body: [String: Any] = [
        "releaseId": releaseId,
        "deviceId": deviceId,
        "token": token,
        "platform": "ios"
    ]

    var request = URLRequest(url: URL(string: "\(baseUrl)/api/public/register-push-token")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONSerialization.data(withJSONObject: body)

    URLSession.shared.dataTask(with: request).resume() // best-effort, fire and forget
}
```

```swift
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
    guard let fcmToken else { return }
    qaPlatformClient.registerPushToken(releaseId: releaseId, deviceId: deviceId, token: fcmToken)
}
```
iOS (Objective-C)objc
# iOS (Objective-C) — no plugin required

Same two endpoints as the [Swift example](ios-swift.md), via `NSURLSession`.

```objc
#import <Network/Network.h>

@interface QaPlatformClient : NSObject
@property (nonatomic, copy) NSString *baseUrl;
@property (nonatomic, copy) NSString *token;
@property (nonatomic, strong) NSString *latestNetworkType;
@property (nonatomic, strong) nw_path_monitor_t pathMonitor;
@end

@implementation QaPlatformClient

- (instancetype)initWithBaseUrl:(NSString *)baseUrl token:(NSString *)token {
    self = [super init];
    if (self) {
        _baseUrl = baseUrl;
        _token = token;
        _latestNetworkType = @"none";

        // No public iOS API reports wifi signal strength (Apple removed
        // CTTelephonyNetworkInfo-based RSSI access years ago) — this
        // monitor only tells us wifi vs. cellular vs. none, cached from
        // the last path update so reportCrash can read it synchronously.
        _pathMonitor = nw_path_monitor_create();
        nw_path_monitor_set_queue(_pathMonitor, dispatch_get_global_queue(QOS_CLASS_UTILITY, 0));
        __weak typeof(self) weakSelf = self;
        nw_path_monitor_set_update_handler(_pathMonitor, ^(nw_path_t path) {
            if (nw_path_uses_interface_type(path, nw_interface_type_wifi)) {
                weakSelf.latestNetworkType = @"wifi";
            } else if (nw_path_uses_interface_type(path, nw_interface_type_cellular)) {
                weakSelf.latestNetworkType = @"cellular";
            } else {
                weakSelf.latestNetworkType = @"none";
            }
        });
        nw_path_monitor_start(_pathMonitor);
    }
    return self;
}

- (void)checkForUpdateWithCurrentVersion:(NSString *)currentVersion
                               completion:(void (^)(NSDictionary *json, NSError *error))completion {
    NSString *urlString = [NSString stringWithFormat:@"%@/api/v1/check-update?platform=ios&currentVersion=%@",
                            self.baseUrl, currentVersion];
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    [request setValue:[NSString stringWithFormat:@"Bearer %@", self.token] forHTTPHeaderField:@"Authorization"];

    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
        completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
            if (error) { completion(nil, error); return; }
            NSError *jsonError;
            NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError];
            completion(json, jsonError);
        }];
    [task resume];
}

- (void)reportCrashWithReleaseId:(NSString *)releaseId
                    exceptionType:(NSString *)exceptionType
                          message:(NSString *)message
                       stackTrace:(NSString *)stackTrace {
    NSMutableDictionary *body = [@{
        @"releaseId": releaseId,
        @"exceptionType": exceptionType,
        @"deviceModel": [UIDevice currentDevice].model,
        @"osVersion": [UIDevice currentDevice].systemVersion,
        // Since iOS 16, UIDevice.currentDevice.name returns a generic,
        // non-identifying string (e.g. "iPhone") unless the app holds
        // Apple's user-assigned-device-name entitlement.
        @"deviceName": [UIDevice currentDevice].name,
        @"osName": @"iOS",
        @"networkType": self.latestNetworkType
        // wifiSignalStrength intentionally omitted — no public iOS API
        // for real RSSI/signal strength.
    } mutableCopy];
    if (message) body[@"message"] = message;
    if (stackTrace) body[@"stackTrace"] = stackTrace;

    NSString *urlString = [NSString stringWithFormat:@"%@/api/public/crash-report", self.baseUrl];
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    request.HTTPMethod = @"POST";
    [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
        completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { /* best-effort */ }];
    [task resume];
}

@end
```

## Auto-capturing crashes

```objc
NSSetUncaughtExceptionHandler(&HandleUncaughtException);

void HandleUncaughtException(NSException *exception) {
    [qaPlatformClient reportCrashWithReleaseId:releaseId
                                  exceptionType:exception.name
                                        message:exception.reason
                                     stackTrace:[exception.callStackSymbols componentsJoinedByString:@"\n"]];
}
```

Same caveat as the Swift example: this only catches `NSException`s, not
Swift traps or native (Mach/BSD signal) crashes.

## Sending feedback

The in-app equivalent of the web share page's "Report an issue" form.

```objc
- (void)sendFeedbackWithReleaseId:(NSString *)releaseId
                           feedback:(NSString *)feedback
                       reporterName:(NSString *)reporterName
                      reporterEmail:(NSString *)reporterEmail {
    NSMutableDictionary *body = [@{
        @"releaseId": releaseId,
        @"feedback": feedback,
        @"deviceModel": [UIDevice currentDevice].model,
        @"osName": @"iOS",
        @"osVersion": [UIDevice currentDevice].systemVersion
    } mutableCopy];
    if (reporterName) body[@"reporterName"] = reporterName;
    if (reporterEmail) body[@"reporterEmail"] = reporterEmail;

    NSString *urlString = [NSString stringWithFormat:@"%@/api/public/report-issue", self.baseUrl];
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    request.HTTPMethod = @"POST";
    [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
        completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { /* best-effort */ }];
    [task resume];
}
```

## Confirming an install

Call `confirmInstallWithReleaseId:...` once on first launch after install —
this is what lets qa-platform report "confirmed installs" (rather than just
install clicks) to your project's analytics. Best-effort and fire-and-forget.

```objc
- (void)confirmInstallWithReleaseId:(NSString *)releaseId deviceId:(NSString *)deviceId {
    NSMutableDictionary *body = [@{
        @"releaseId": releaseId,
        @"deviceModel": [UIDevice currentDevice].model,
        @"osVersion": [UIDevice currentDevice].systemVersion,
        @"appVersion": [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @""
    } mutableCopy];
    if (deviceId) body[@"deviceId"] = deviceId;

    NSString *urlString = [NSString stringWithFormat:@"%@/api/public/install-receipt", self.baseUrl];
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    request.HTTPMethod = @"POST";
    [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
        completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { /* best-effort */ }];
    [task resume];
}
```

## Receiving push notifications

Register your device's FCM token so qa-platform can push you the moment a
new release publishes on your channel — instead of only finding out next
time you call `checkForUpdate`. This snippet only relays a token you've
already obtained from the Firebase iOS SDK (`FIRMessaging`, a peer
dependency this snippet doesn't set up) — call it once you have a token,
and again from your `FIRMessagingDelegate`'s
`messaging:didReceiveRegistrationToken:` whenever Firebase reissues one:

```objc
- (void)registerPushTokenWithReleaseId:(NSString *)releaseId deviceId:(NSString *)deviceId token:(NSString *)token {
    NSDictionary *body = @{
        @"releaseId": releaseId,
        @"deviceId": deviceId,
        @"token": token,
        @"platform": @"ios"
    };

    NSString *urlString = [NSString stringWithFormat:@"%@/api/public/register-push-token", self.baseUrl];
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    request.HTTPMethod = @"POST";
    [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
        completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { /* best-effort */ }];
    [task resume];
}
```

```objc
- (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSString *)fcmToken {
    [qaPlatformClient registerPushTokenWithReleaseId:releaseId deviceId:deviceId token:fcmToken];
}
```
Flutter (Dart)dart
# Flutter (Dart) — no plugin required

Uses the `http` package (`dependencies: http: ^1.0.0` in `pubspec.yaml`).
`platform` is picked from `dart:io`'s `Platform` — Flutter compiles to a
real `.ipa`/`.apk`, so it's indistinguishable from a native app to
qa-platform's release/update APIs. `reportCrash`'s `networkType` uses the
`connectivity_plus` package (`dependencies: connectivity_plus: ^6.0.0`) —
pure Dart has no connectivity API of its own. There's no Dart (or
platform-channel-free) way to read real wifi RSSI at all: Android's
`WifiManager` isn't exposed to Dart without writing your own platform
channel (see android-kotlin.md/android-java.md for the native side to
bridge to), and iOS has no public RSSI API regardless of how you reach
it — so `wifiSignalStrength` is always left unset here.

```dart
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:http/http.dart' as http;

class QaPlatformClient {
  final String baseUrl;
  final String token;

  QaPlatformClient({required this.baseUrl, required this.token});

  String get _platform => Platform.isIOS ? 'ios' : 'android';

  Future<String> _currentNetworkType() async {
    final result = await Connectivity().checkConnectivity();
    if (result.contains(ConnectivityResult.wifi)) return 'wifi';
    if (result.contains(ConnectivityResult.mobile)) return 'cellular';
    return 'none';
  }

  Future<Map<String, dynamic>> checkForUpdate(String currentVersion) async {
    final uri = Uri.parse('$baseUrl/api/v1/check-update').replace(queryParameters: {
      'platform': _platform,
      'currentVersion': currentVersion,
    });
    final res = await http.get(uri, headers: {'Authorization': 'Bearer $token'});
    if (res.statusCode >= 400) {
      throw Exception('check-update failed: ${res.statusCode} ${res.body}');
    }
    return jsonDecode(res.body) as Map<String, dynamic>;
  }

  Future<void> reportCrash({
    required String releaseId,
    required String exceptionType,
    String? message,
    String? stackTrace,
  }) async {
    await http.post(
      Uri.parse('$baseUrl/api/public/crash-report'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'releaseId': releaseId,
        'exceptionType': exceptionType,
        if (message != null) 'message': message,
        if (stackTrace != null) 'stackTrace': stackTrace,
        'osName': Platform.isIOS ? 'iOS' : 'Android',
        'osVersion': Platform.operatingSystemVersion,
        'networkType': await _currentNetworkType(),
        // wifiSignalStrength intentionally omitted — see note above.
      }),
    );
  }
}
```

## Auto-capturing crashes

Flutter has two error surfaces — catch both, since Dart exceptions inside
the framework don't flow through the platform-level handler:

```dart
void main() {
  final client = QaPlatformClient(baseUrl: 'https://your-app.vercel.app', token: 'qap_...');
  const releaseId = String.fromEnvironment('QA_PLATFORM_RELEASE_ID');

  // Flutter framework errors (widget build/layout/paint)
  FlutterError.onError = (details) {
    client.reportCrash(
      releaseId: releaseId,
      exceptionType: details.exception.runtimeType.toString(),
      message: details.exceptionAsString(),
      stackTrace: details.stack.toString(),
    );
    FlutterError.presentError(details);
  };

  // Everything else (async errors, isolate errors)
  PlatformDispatcher.instance.onError = (error, stack) {
    client.reportCrash(
      releaseId: releaseId,
      exceptionType: error.runtimeType.toString(),
      message: error.toString(),
      stackTrace: stack.toString(),
    );
    return true;
  };

  runApp(const MyApp());
}
```

Pass `QA_PLATFORM_RELEASE_ID` at build time with
`flutter build apk --dart-define=QA_PLATFORM_RELEASE_ID=<id-from-ci>`.

## Sending feedback

The in-app equivalent of the web share page's "Report an issue" form.

```dart
Future<void> sendFeedback({
  required String releaseId,
  required String feedback,
  String? reporterName,
  String? reporterEmail,
}) async {
  await http.post(
    Uri.parse('$baseUrl/api/public/report-issue'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({
      'releaseId': releaseId,
      'feedback': feedback,
      if (reporterName != null) 'reporterName': reporterName,
      if (reporterEmail != null) 'reporterEmail': reporterEmail,
      'osName': Platform.isIOS ? 'iOS' : 'Android',
      'osVersion': Platform.operatingSystemVersion,
    }),
  );
}
```

## Confirming an install

Call `reportInstall()` once on first launch after install — this is what
lets qa-platform report "confirmed installs" (rather than just install
clicks) to your project's analytics. Best-effort and fire-and-forget; it
never blocks your app's startup.

```dart
Future<void> reportInstall({String? deviceId}) async {
  try {
    await http.post(
      Uri.parse('$baseUrl/api/public/install-receipt'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'releaseId': releaseId,
        'deviceModel': _deviceModel,
        'osVersion': Platform.operatingSystemVersion,
        'appVersion': _appVersion,
        if (deviceId != null) 'deviceId': deviceId,
      }),
    );
  } catch (_) {
    // best-effort — never let install confirmation itself throw
  }
}
```

## Receiving push notifications

Register your device's FCM token so qa-platform can push you the moment a
new release publishes on your channel — instead of only finding out next
time you call `checkForUpdate`. This snippet only relays a token you've
already obtained from the `firebase_messaging` package (`dependencies:
firebase_messaging: ^15.0.0`, a peer dependency this snippet doesn't set
up) — call it once you have a token, and again from
`FirebaseMessaging.instance.onTokenRefresh` whenever Firebase reissues one:

```dart
Future<void> registerPushToken({required String deviceId, required String token}) async {
  try {
    await http.post(
      Uri.parse('$baseUrl/api/public/register-push-token'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'releaseId': releaseId,
        'deviceId': deviceId,
        'token': token,
        'platform': Platform.isIOS ? 'ios' : 'android',
      }),
    );
  } catch (_) {
    // best-effort — never let token registration itself throw
  }
}
```

```dart
final token = await FirebaseMessaging.instance.getToken();
if (token != null) client.registerPushToken(deviceId: deviceId, token: token);

FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
  client.registerPushToken(deviceId: deviceId, token: newToken);
});
```