Today WebView Bridge
Contract

Message Envelope

The request shape, type dispatch, reply shape, and transport requirements.

Request

Every message is a JSON-serialisable object with a required string type. Capability-specific fields sit alongside it on the same object — the envelope is flat, not nested.

type BridgeMessage =
  | { type: 'track'; ename: string; parameters?: Record<string, string | number | boolean> }
  | { type: 'headers' }
  | { type: 'refreshToken' }
  | { type: 'establishSession'; refresh?: boolean }
  • type MUST be present and a string.
  • The host MUST dispatch on type alone.
  • Additional fields not described by a capability SHOULD be ignored by the host (forward compatibility).

Reply

The host answers with a settled Promise on the web side:

  • Resolve with the capability's result value (which MAY be undefined).
  • Reject with an error — see Error handling.

There is no envelope around the reply: the resolved value is the capability's result, not a { data } wrapper. Errors are the one exception, because the transports encode them out-of-band (an error string, not a thrown value).

Type dispatch

            ┌── "track"            → enrich + forward, resolve undefined
message.type├── "headers"          → resolve Record<string,string>
            ├── "refreshToken"     → refresh, resolve { authorization } | reject
            ├── "establishSession" → mint cookie natively, resolve { ok: boolean }
            └── anything else      → resolve undefined

An unrecognised or missing type MUST resolve with undefined. It MUST NOT reject. This is the forward-compatibility hinge: a web build that calls a capability an older host has never heard of gets undefined rather than a crash.

Transport requirements

The contract is transport-agnostic, but both platforms MUST present the channel as an object with a promise-returning postMessage:

interface NativeChannel {
  postMessage(message: BridgeMessage): Promise<unknown>
}
PlatformWhere the channel livesUnderlying mechanism
iOSwindow.webkit.messageHandlers.todayWebViewBridgeWKScriptMessageHandlerWithReply (postMessage already returns a Promise)
Androidwindow.todayWebViewBridgeHost-injected JS shim that returns a Promise (see below)

The host MUST satisfy these rules:

  1. Serialisation. Messages are structured-cloneable JSON. The host MUST accept any value the capabilities define and SHOULD tolerate unknown fields.
  2. One settlement per message. Each postMessage settles exactly once.
  3. No ordering guarantee. Replies MAY arrive out of order relative to requests. Capabilities that need correlation carry their own identifiers; the v1 capabilities do not require any.
  4. Idempotent transport. Re-sending the same message MUST be safe at the transport layer (capability-level effects are defined per capability — e.g. refreshToken coalescing).
  5. Token-bearing capabilities are main-frame only. The host MUST expose headers, refreshToken, and establishSession to the top-level document frame only. A call dispatched from a cross-origin sub-frame MUST resolve undefined; a token MUST NOT cross into a sub-frame (and no cookie is minted). track carries no token and is not restricted. See Capabilities.

Android promise semantics

iOS's WKScriptMessageHandlerWithReply returns a Promise natively. Android's raw @JavascriptInterface is synchronous and string-only, so a conforming Android host MUST inject a thin JS shim that presents the same postMessage(message): Promise surface — typically backed by AndroidX WebViewCompat.addWebMessageListener with request-id correlation, or by a synchronous @JavascriptInterface call paired with a callback. The web side never sees the difference. See Android.

On this page