The events HTTP contract

Send events from Swift, Kotlin or anything else: the request, the limits, privacy, and how to check it worked.

The HTTP contract

If neither client fits — an Android app, a server, another language — send the same request yourself. There is nothing else to it.

POST /v1/events
POST https://api.broctic.com/v1/events
Content-Type: application/json
x-broctic-key: bpk_…

{
  "events": [
    {
      "name": "paywall_viewed",
      "ts": "2026-09-07T18:00:00Z",
      "anonymousId": "3f0c…",
      "userId": "optional, only after sign-in",
      "sessionId": "optional",
      "props": { "placement": "onboarding" }
    }
  ],
  "context": {
    "platform": "ios",
    "appVersion": "1.4.0",
    "osVersion": "18.0",
    "locale": "en-CA",
    "country": "CA"
  }
}

→ 202 { "accepted": 1 }
  • 202 with { "accepted": n } — received.
  • 4xx — the batch itself is wrong: bad key, bad event name, nested props. Retrying will not help. Fix it.
  • 5xx or a network failure — retry on the next flush. Do not drop the batch.

Kotlin (OkHttp, or HttpURLConnection)

BrocticAnalytics.kt
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject

private val client = OkHttpClient()
private val JSON = "application/json".toMediaType()

fun sendBroctic(events: JSONArray, publishableKey: String) {
    val body = JSONObject()
        .put("events", events)
        .put(
            "context",
            JSONObject()
                .put("platform", "android")
                .put("appVersion", BuildConfig.VERSION_NAME)
                .put("osVersion", android.os.Build.VERSION.RELEASE),
        )

    val request = Request.Builder()
        .url("https://api.broctic.com/v1/events")
        .addHeader("x-broctic-key", publishableKey)
        .post(body.toString().toRequestBody(JSON))
        .build()

    client.newCall(request).enqueue(object : Callback {
        override fun onFailure(call: Call, e: java.io.IOException) = requeue(events)
        override fun onResponse(call: Call, response: Response) {
            // Retry server errors; drop 4xx — the batch itself is wrong.
            if (response.code >= 500) requeue(events)
            response.close()
        }
    })
}

// Without OkHttp, HttpURLConnection does the same job:
//   val conn = URL(url).openConnection() as HttpURLConnection
//   conn.requestMethod = "POST"
//   conn.setRequestProperty("Content-Type", "application/json")
//   conn.setRequestProperty("x-broctic-key", publishableKey)
//   conn.doOutput = true
//   conn.outputStream.use { it.write(body.toString().toByteArray()) }
//   val code = conn.responseCode

Privacy

The funnel counts people, not identities, and it does not need to know who they are to do that.

  • anonymousId is a random id the app generates once and stores. It is not derived from the device, so it cannot be correlated with anything outside your app.
  • No device identifiers. Not the IDFA, not the IDFV, not an advertising id. Nothing here requires App Tracking Transparency, because nothing here tracks anyone across apps or websites.
  • userId is optional, sent only after sign-in, and should be your own opaque user id — never an email address or a phone number.
  • Context is coarse by design: platform, app version, OS version, locale, two-letter country. No IP-derived location is stored beside events.
  • Property values are yours to choose, so do not put personal data in them. Send a product id, not a customer name.

Check it worked

Open the app's page in the console and go to Events. Every name received in the window is listed with its event and person counts, and marked as standard or custom. A name you expected to see marked custom means it is misspelled against the vocabulary — fix the app rather than renaming the funnel step, or the benchmark comparison stays broken.

Letting an AI assistant do the instrumentation? Hand it the instruction page, which contains this contract, the client file and a verification step in one paste.