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.
/api/ci/releases/createPublish 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
/api/v1/releasesList the project's published releases, newest first (?limit=1-100, default 20).
curl https://your-app/api/v1/releases \ -H "Authorization: Bearer qap_..."
/api/v1/releases/:idFetch a single release's detail.
curl https://your-app/api/v1/releases/<release-id> \ -H "Authorization: Bearer qap_..."
/api/v1/check-updateAsk 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¤tVersion=1.2.0" \ -H "Authorization: Bearer qap_..."
/api/public/crash-reportReport 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.
}),
});/api/public/report-issueSend 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(),
}),
});/api/public/install-receiptConfirm 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,
}),
});/api/public/register-push-tokenRegister 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,
}),
});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}¤tVersion=${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) },
]
);
}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:
@qa-platform/capacitor-plugin— a real plugin with web, Android (Kotlin), and iOS (Swift) implementations. See sdk/capacitor-plugin.# 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¤tVersion=$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) — 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¤tVersion=" + 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) — 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) — 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¤tVersion=%@",
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) — 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);
});
```