Skip to main content
This guide explains how to send events to the web journey, handle incoming requests, and build request-response flows.

Message Types Overview

GBGBridge uses three message types:
TypeDirectionPurpose
RequestWeb → NativeThe web journey asks the host to do something. Expects a response.
ResponseNative → WebThe host returns the result of a request.
EventEither directionAn asynchronous notification. No response expected.
All messages share the same envelope structure (BridgeMessage) with a correlationId for pairing requests and responses. Message data values are kotlinx.serialization.json.JsonElement — build payloads with JsonPrimitive(...) or buildJsonObject { }, and read values with extensions like jsonPrimitive.contentOrNull.
BridgeHost is main-thread-only. Every state-mutating call (sendEvent, respond, register, and the rest) asserts the main thread at runtime and throws IllegalStateException off-main. Inbound messages from the web journey are posted to the main looper before any handler or delegate runs. If you do work on a background thread, hop back before touching the host — withContext(Dispatchers.Main), Handler(Looper.getMainLooper()).post { }, or runOnUiThread { }.

Sending Events to the Web Journey

Events are fire-and-forget messages from the native host to the web journey. Use them to notify the web journey of state changes, user actions, or lifecycle transitions.
// Send a simple event
host.sendEvent("host.ready", data = mapOf(
  "timestamp" to JsonPrimitive(System.currentTimeMillis())
))

// Send an event with structured data
host.sendEvent("user.action", data = mapOf(
  "action" to JsonPrimitive("tapped_help"),
  "screen" to JsonPrimitive("document_capture")
))

// Nested objects via buildJsonObject
host.sendEvent("host.deviceInfo", data = mapOf(
  "device" to buildJsonObject {
    put("model", Build.MODEL)
    put("osVersion", Build.VERSION.RELEASE)
  }
))
sendEvent returns Unit — there is no delivery confirmation. The SDK generates a correlationId of the form android-event-{uuid} for each event (the iOS SDK uses ios-event-{uuid}).

Common Event Patterns

Event ActionWhen to SendExample Data
host.readyAfter host initialization completes{ timestamp }
host.backgroundApp enters background{}
host.foregroundApp returns to foreground{}
journey.cancelUser taps a cancel/back button{ reason }
host.orientationDevice orientation changes{ orientation }

Handling Incoming Requests

When the web journey sends a request, GBGBridge routes it to a registered handler or adds it to pendingRequests. For capture operations, use the typed slots on BridgeHost. Setting a handler declares support and routes requests automatically. The handler is a suspend lambda that runs on Dispatchers.Main:
val host = BridgeHost(hostVersion = "1.0.0")

host.documentCapture.handler = { request ->
  showCaptureUi()
  host.documentCapture.awaitCompletion()
}
Complete the request from your camera UI callback:
// Success
host.documentCapture.complete(CaptureResult.Document(
  imageData = imageBytes, width = 1920, height = 1080
))

// Cancellation
host.documentCapture.complete(CaptureResult.Cancelled(reason = "User dismissed"))

// Failure
host.documentCapture.complete(CaptureResult.Failed(
  code = "CAMERA_DENIED", message = "Permission denied", recoverable = true
))
The SDK encodes CaptureResult into the bridge protocol format automatically. See the Capability Handling Guide for the full Compose integration pattern.

Using Custom Capabilities or BridgeCapabilityHandler

For other capabilities, use registerCustomCapability() or implement BridgeCapabilityHandler:
// Custom capability (lambda-based)
host.registerCustomCapability("device.info") { request, responder ->
  responder.respond(
    status = BridgeResponseStatus.SUCCESS,
    data = mapOf("model" to JsonPrimitive(Build.MODEL))
  )
}

// BridgeCapabilityHandler interface (class-based)
host.register(handler = DeviceInfoHandler())
Unlike iOS, where handle is async, the Kotlin BridgeCapabilityHandler.handle(request, responder) signature is synchronous. For asynchronous work, retain the responder, do the work off the main thread, then hop back to the main thread and respond:
class DeviceInfoHandler(private val scope: CoroutineScope) : BridgeCapabilityHandler {
  override val action = "device.info"

  override fun handle(request: BridgeMessage, responder: BridgeResponder) {
    scope.launch(Dispatchers.IO) {
      val model = loadDeviceModel()
      withContext(Dispatchers.Main) {
        responder.respond(
          status = BridgeResponseStatus.SUCCESS,
          data = mapOf("model" to JsonPrimitive(model))
        )
      }
    }
  }
}
Call responder.respond(...) exactly once — subsequent calls on the same responder are silently ignored.

Handling Requests via the Delegate

For requests that don’t map to a single handler — or when you want centralized request handling — use BridgeHostDelegate. All four delegate methods have default no-op implementations, so override only what you need. onMessageSent and onError are Android-only additions with no iOS equivalent.
class JourneyCoordinator : BridgeHostDelegate {
  override fun onUnhandledRequest(host: BridgeHost, request: BridgeMessage) {
    when (request.payload.action) {
      "journey.complete" -> {
        val result = request.payload.data?.get("result")?.jsonPrimitive?.contentOrNull
        handleJourneyComplete(result)

        host.respond(
          to = request.correlationId,
          status = BridgeResponseStatus.ACKNOWLEDGED
        )
      }

      else -> host.respond(
        to = request.correlationId,
        status = BridgeResponseStatus.UNSUPPORTED,
        error = BridgeErrorPayload(
          code = "UNSUPPORTED_ACTION",
          message = "Action '${request.payload.action}' is not supported",
          recoverable = false
        )
      )
    }
  }

  override fun onMessage(host: BridgeHost, message: BridgeMessage) {
    // Log all inbound messages for debugging
    Log.d("Bridge", "${message.type}: ${message.payload.action}")
  }

  private fun handleJourneyComplete(result: String?) {
    // Navigate to results screen, etc.
  }
}
Set the delegate on your host:
val coordinator = JourneyCoordinator() // keep a strong reference
host.delegate = coordinator
The delegate property is backed by a WeakReference — the host does not keep your delegate alive. Assigning an inline instance (host.delegate = JourneyCoordinator()) with no other strong reference means callbacks silently stop firing once the delegate is garbage collected. Hold the delegate in a property of an object with a matching lifetime (Activity, Fragment, ViewModel, or a remember-ed controller in Compose).

Responding to Pending Requests Manually

If a request arrives with no registered handler, it is stored in pendingRequests (and onUnhandledRequest fires). You can respond to it later:
// In a Compose screen, with pendingRequests copied into state
// from a delegate callback
pendingRequests.forEach { request ->
  key(request.correlationId) {
    Row {
      Text(request.payload.action)
      Button(onClick = {
        host.respond(
          to = request.correlationId,
          status = BridgeResponseStatus.SUCCESS,
          data = mapOf("approved" to JsonPrimitive(true))
        )
      }) { Text("Approve") }
      Button(onClick = {
        host.respond(
          to = request.correlationId,
          status = BridgeResponseStatus.CANCELLED
        )
      }) { Text("Deny") }
    }
  }
}
pendingRequests is an immutable snapshot taken on each read — it is not observable. Re-read it after delegate callbacks fire and copy it into your own UI state. BridgeMessage has no id property on Android; key Compose lists by correlationId.

Choosing a respond Overload

BridgeHost has two respond overloads, and they behave differently. Both return Unit. The lookup overload — respond(to, status, data, error) — finds the matching request in pendingRequests by correlationId, removes it, and sends the response using the action recorded on the original request. If no pending request matches the correlationId, the call silently no-ops. Use it for requests that landed on the unhandled path. The explicit-action overload — respond(to, action, status, data, error) — sends a response without requiring a pending entry; you supply the action yourself. It deduplicates by correlationId: a second call with the same correlationId silently no-ops. If the send fails (encode error, or no WebView attached), the dedupe entry is rolled back so a retry with the same correlationId works once the underlying problem is fixed.
There is a retry asymmetry between the overloads. The lookup overload consumes the pending entry before sending — if that send then fails, retrying via the lookup overload silently no-ops because the entry is gone. Retry with the explicit-action overload instead, recovering the action from the original BridgeMessage (for example, one you captured in onUnhandledRequest).

Response Patterns

When responding to a request, you supply a status, optional data, and an optional error payload. The patterns below cover the three response shapes you’ll use most: success-with-data, error, and cancellation.

Success with Data

responder.respond(
  status = BridgeResponseStatus.SUCCESS,
  data = mapOf(
    "imageBase64" to JsonPrimitive("..."),
    "width" to JsonPrimitive(1920),
    "height" to JsonPrimitive(1080)
  )
)

Error with Details

responder.respond(
  status = BridgeResponseStatus.ERROR,
  error = BridgeErrorPayload(
    code = "PERMISSION_DENIED",
    message = "Camera permission was denied by the user",
    recoverable = true  // User can grant permission in Settings
  )
)

User Cancellation

responder.respond(status = BridgeResponseStatus.CANCELLED)

Unsupported Action

responder.respond(
  status = BridgeResponseStatus.UNSUPPORTED,
  error = BridgeErrorPayload(
    code = "NOT_AVAILABLE",
    message = "This capability is not available on this device",
    recoverable = false
  )
)

Acknowledged (Async Processing)

Use ACKNOWLEDGED when the operation will take time and you want to inform the web journey that the request was received.
// First, acknowledge receipt
responder.respond(
  status = BridgeResponseStatus.ACKNOWLEDGED,
  data = mapOf("estimatedDuration" to JsonPrimitive(5000))
)

// Later, send the actual result as an event
host.sendEvent("camera.capture.complete", data = mapOf(
  "imageBase64" to JsonPrimitive("..."),
  "correlationId" to JsonPrimitive(request.correlationId)
))

Observing All Messages

The Android SDK has no Combine-style publishers. Observe traffic through the delegate callbacks, and read the receivedMessages / pendingRequests snapshot properties when you need current state:
class MessageLogger : BridgeHostDelegate {
  override fun onMessage(host: BridgeHost, message: BridgeMessage) {
    // Every inbound message
    Log.d("Bridge", "Total messages: ${host.receivedMessages.size}")
    if (message.payload.action == "journey.progress") {
      // Update progress UI
    }
  }

  override fun onMessageSent(host: BridgeHost, message: BridgeMessage) {
    // Every outbound message, fired before transport (Android-only)
    Log.d("Bridge", "Outbound: ${message.payload.action}")
  }
}
receivedMessages holds every inbound message, capped at 200 entries (BridgeHost.MAX_RECEIVED_MESSAGES) — older entries are evicted from the head. Both properties return an immutable copy on each read, so for Compose, copy them into your own state from the delegate callbacks rather than reading them directly in composition.
onMessageSent fires for every outbound envelope the SDK was asked to send — including envelopes that are then dropped at transport (no WebView attached) or fail to encode. Treat it as an intent trace, not delivery confirmation.

Error Handling

Bridge messaging surfaces errors through two channels: lastError, a read-only string property on the host (clear it with clearError(); hosts cannot write it directly, unlike iOS), and BridgeHostDelegate.onError, an Android-only Throwable channel that fires for decode failures, handler exceptions, encode failures, and origin-gate rejections.
override fun onError(host: BridgeHost, error: Throwable) {
  Log.e("Bridge", "Bridge error", error)
}

Encoding/Decoding Errors

If GBGBridge cannot decode an incoming message, lastError is set with a description, onError fires, and the message is not added to receivedMessages. If an outbound message fails to encode, lastError is set, onError fires, and the message is dropped — the explicit-action respond overload rolls back its dedupe entry in this case so a retry can succeed.

Sending While Detached

If you call sendEvent or respond before attaching a WebView (or after detach()), the message is silently dropped at transport. onMessageSent still fires — it records intent, not delivery — and no lastError is recorded. This deliberately diverges from iOS, which sets lastError = "WebView not attached". Since respond and sendEvent return Unit, there is no per-call failure signal; make sure the host is attached before sending.

Handler Exceptions

Exceptions thrown by capability handlers are caught by the SDK and routed to onError. If the handler had not yet responded, lastError is also set and an ERROR response with code HANDLER_FAILURE (recoverable = false) is dispatched to the web journey. A handler that responded successfully and then threw is reported via onError only — the request itself still succeeded.

JavaScript Delivery

Outbound delivery into the page is fire-and-forget: the injected script no-ops when window.GBGBridge.receive is not defined (for example, after the page has navigated away), and no error is reported back to the host. This differs from iOS, where WebKit evaluation failures set lastError.

Next Steps