Today WebView Bridge
Web SDK

API Reference

Full signatures for the @todayai-labs/webview-bridge client.

createWebViewBridge

function createWebViewBridge(options?: WebViewBridgeOptions): WebViewBridge

Resolves the active host channel (window.webkit.messageHandlers.todayWebViewBridge on iOS/macOS or window.todayWebViewBridge on Android/Linux/Windows) and returns a client bound to it. If neither channel exists, the returned client is fully functional but inert — see safe degradation.

interface WebViewBridgeOptions {
  /**
   * Override the global lookup name. Defaults to "todayWebViewBridge".
   * Intended for staged channel migrations.
   */
  channelName?: string
  /**
   * Inject a channel directly, bypassing the window lookup.
   * Intended for tests and custom transports.
   */
  channel?: NativeChannel
}

isAvailable

isAvailable(): boolean

Returns true only when a Today host channel is present. Use it to gate host-only features. It performs no I/O and is safe to call synchronously during render.

isAvailable() and platform are environment hints, not a security boundary — they read page-visible globals that page JS can spoof. Rely on them for rendering and feature-gating, never for a trust decision. See Not a security boundary.

platform

  readonly platform: Platform // 'ios' | 'android' | 'linux' | 'macos' | 'windows' | 'web'

The detected platform, resolved synchronously at createWebViewBridge() time — not a method, not a Promise. Safe to read during render. 'web' means no host bridge (the same condition as isAvailable() === false). See the Environment detection contract for how it is resolved and what the host must inject to disambiguate shared transports.

if (bridge.platform === 'ios' || bridge.platform === 'macos') {
  // Apple-specific layout
}

track

track(event: string, properties?: TrackProperties): void

Reports an analytics event. Fire-and-forget: returns void, never throws, and must not be awaited. The host enriches the event with base fields and forwards it to PostHog.

type TrackProperties = Record<string, string | number | boolean>

properties accepts scalar values only. Nested array / object / null values are dropped by the host. The public (event, properties) arguments map onto the wire fields ename / parameters, which stay stable for the analytics pipeline. See the track contract.

getHeaders

getHeaders(): Promise<Record<string, string>>

Returns the headers the web must attach to its own fetch / XHR requests — Authorization plus any server-driven extra headers. Never rejects: resolves to {} when no host is present and on any transport fault. Callers (and the embed boot path) rely on this not throwing.

const headers = await bridge.getHeaders()
// { Authorization: "Bearer …", "X-Today-Extra": "…", … }

The result depends on the current page URL and login state. Outside a trusted feed domain, or when logged out, Authorization may be absent. Never assume it is present. See the headers contract.

refreshToken

refreshToken(): Promise<RefreshTokenResult>

Forces the host to mint a fresh access token. Call it when one of your own requests returns 401. Concurrent calls are coalesced by the host into a single real refresh, so it is safe to call from multiple in-flight requests at once.

interface RefreshTokenResult {
  authorization: string // ready-to-use header value, e.g. "Bearer …"
}

The result carries only authorization (a ready-to-use header value) — never the raw token. The SDK normalises the host reply to exactly this field and ignores any extras, keeping the secret surface minimal.

Rejects with one of three BridgeError types — distinguish them, because only one means the user is signed out:

RejectionCauseForce re-auth?
BridgeUnavailableErrorNo native hostNo — no host
BridgeErrorHost rejected (terminal auth failure)Yes
BridgeProtocolErrorHost replied with a malformed / empty resultNo — host bug

A BridgeProtocolError is a protocol/serialization bug on the native side, not a signed-out user — do not force re-authentication on it. Always wrap the call:

try {
  const { authorization } = await bridge.refreshToken()
  retryWith(authorization)
} catch (e) {
  if (e instanceof BridgeProtocolError) {
    // malformed reply — host bug, not a real auth failure; do not re-auth
  }
  // BridgeUnavailableError → no host; plain BridgeError → terminal auth failure
}

See the refreshToken contract and Error handling.

emit

emit(type: string, payload?: Record<string, unknown>): void

Posts a custom app-specific event through the same native channel. Fire-and-forget: returns void, never throws, and must not be awaited.

bridge.emit('todayPage.firstBatchRendered', {
  schemaVersion: 1,
  requestId: 'refresh-...',
  batchId: 'batch-a',
})

The SDK sends one flat wire object:

{ ...payload, type }

If payload also contains a type field, the first argument wins.

Use emit for additive, app-specific events whose result is not needed by the web page. The host should resolve undefined after accepting the event. See the Today Page V2 contract for the native Today tab lifecycle events built on this helper.

onNativeEvent

onNativeEvent(handler: (event: NativeEventMessage) => void): () => void

Subscribes to native → web lifecycle events delivered through todayWebViewBridge:nativeEvent. Returns an unsubscribe function.

const unsubscribe = bridge.onNativeEvent((event) => {
  if (event.type === 'webView.visibilityChanged') {
    // event.visible is the native-visible state
  }
})

The SDK only validates that event.type is a non-empty string. Unknown event types are ignored by consumers, not rejected by the transport.

establishSession

establishSession(options?: { refresh?: boolean }): Promise<boolean>

Asks the host to mint the same-origin embed_bearer session cookie itself. The bearer is written into the WebView cookie store natively and is never returned to JS — the call resolves a plain boolean, not a token. Pass { refresh: true } to force a fresh server token before the mint (the 401 path).

Never throws. Resolves false with no host, an unsupported (older) host, or a failed mint — so the caller falls back to the header bootstrap:

// Preferred bootstrap: native mint, header exchange as the fallback.
if (!(await bridge.establishSession())) {
  await bootstrapFromHeaders() // getHeaders() → POST /api/auth/embed-session
}

// After a 401: force a refresh, then re-mint.
if (!(await bridge.establishSession({ refresh: true }))) {
  await refreshFromHeaders() // refreshToken() → POST /api/auth/embed-session
}

This is the hardened path: because the cookie is HttpOnly, the bearer never enters page JS at all. See the establishSession contract and the Security model.

fetch

fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>

A convenience wrapper around the platform fetch that:

  1. calls getHeaders() and merges the result into the request headers (your explicit init.headers win over injected ones on this initial request);
  2. issues the request;
  3. if the response is 401 and the request is replayable, calls refreshToken() and retries once with the refreshed Authorization (which takes precedence over the caller header on the retry);
  4. returns the final Response.

A request is not replayable — and so the 401 is returned without a retry — when input is a Request object, or when init.body is a ReadableStream. Both are consumed by the first fetch and cannot be re-sent, so the SDK returns the 401 rather than throwing an opaque "body already used" error. For a retryable authenticated request, pass a URL string with a string / Blob / ArrayBuffer body.

When no host is present it is just fetch — no headers, no retry. This is the recommended entry point for authenticated requests; reach for the lower-level methods only when you need to control merging or retry yourself.

const res = await bridge.fetch('/feed/api/cards', { method: 'GET' })

Errors

All rejections are instances of BridgeError. The unavailable-host case is the BridgeUnavailableError subclass; a malformed host reply is the BridgeProtocolError subclass (a host bug, not a signed-out user). See Error handling for the full taxonomy.

On this page