Skip to main content
This guide explains how the GBGBridge Web SDK handles capabilities — the host-side features that web journeys can request. It covers the two-step declare-then-register pattern, the one-call alternative via registerCustomCapability, permission state, capability negotiation, and graceful-degradation patterns.

What is a capability?

A capability is a host-side feature the host page can fulfil on the journey’s behalf. Examples:
  • camera.document — Document photo capture handled by the host page.
  • camera.selfie — Selfie / liveness capture handled by the host page.
  • analytics.forward — Forward analytics events from the journey to your telemetry pipeline.
Capability IDs are dot-separated string keys. Standard IDs are exported as constants in CAPABILITY_IDS — use them to avoid typos:

Two ways to declare and register

Unlike iOS / Android — which use typed slots that combine declaration and handler registration in a single property assignment — the Web SDK keeps declaration and registration as separate concerns by default. There are two patterns: Both can be used together. registerCustomCapability entries are merged with the constructor’s capabilities map when building capability.query responses.

Two-step: declare via prop, register via hook

This is the default React pattern. The provider’s capabilities prop declares what the host supports; useBridgeCapability attaches the handler that actually fulfils requests.

Declaring capabilities

Pass a CapabilityMap to the provider:
Each CapabilityInfo entry can include:

Registering handlers

Use useBridgeCapability inside any descendant of the provider:
The hook wraps the handler in a stable proxy — inline { handle: ... } objects do not re-register on every render. On unmount the hook unregisters only if its proxy is still the active handler.

Capability ID vs. action ID

These are related but distinct strings. Declaring camera.document does not implicitly register a handler for camera.document.capture. You need both.

One-call: registerCustomCapability

For dynamic capabilities — added after mount, conditional on runtime state, or tied to a non-React coordinator — call host.registerCustomCapability(id, version, handler) directly. It declares the capability AND registers the handler in one step.
The capability appears in the next capability.query response. There is no API to explicitly remove a custom capability — once registered, it lives for the lifetime of the host instance.

Capability collision rules

When the same capability ID appears in both the constructor capabilities map AND a registerCustomCapability call, the constructor wins. The query handler builds responses by merging in this order:
— i.e., custom entries fill in IDs that the constructor didn’t declare, but never override one that did. This matches Android’s “configuration is authoritative” pattern and lets you safely use registerCustomCapability for runtime additions without worrying about clobbering your declared baseline. The action-handler registration is separate: whoever called registerHandler (or registerCustomCapability with a handler) most recently for a given action ID wins. If you need to verify ownership before re-registering, use host.getHandler(action) to inspect the current registration.

Handler lifecycle and exceptions

Capability handlers have a single lifecycle hook — handle(request, responder) — but several distinct exit paths affect what the journey sees.

Successful terminal response

Terminal methods (success, error, cancelled, unsupported) flip the responder’s internal hasResponded flag. Any subsequent terminal call on the same responder is silently a no-op — exactly-once delivery is enforced at the responder level.

acknowledge() for long-running operations

acknowledge() does NOT flip hasResponded and is chainable (returns this). Use it for handlers that exceed the journey-side default timeout (~30s). A second acknowledge() is also a no-op — gated by a separate hasAcknowledged flag so a buggy handler can’t spam duplicate ack envelopes.

Handler throws or rejects before responding

If the handler throws synchronously or its returned Promise rejects, and the responder has not yet been called, the SDK auto-sends a HANDLER_FAILURE response and routes the error to delegate.onError:
The auto-response payload:
Without this, the journey would sit waiting until its own request timeout (~30s) — the user would see a spinner instead of an error. HANDLER_FAILURE is NOT a member of the BridgeErrorCode enum (it’s a raw string emitted on the wire). Journey-side consumers that pipe response.error.code through toBridgeErrorCode(...) see INTERNAL_ERROR (the documented fallback). If you need to distinguish handler failures from genuine INTERNAL_ERROR, match against the raw error.code string before normalising.

Handler throws AFTER responding

If a handler calls responder.success(...) and THEN throws (e.g., from stray cleanup code), the SDK logs a console.warn and does NOT flip the wire response. The journey sees the successful response; only the host’s delegate.onError is notified about the post-terminal throw. This preserves wire truthfulness.

Cleanup on unmount

useBridgeCapability registers on mount and unregisters on unmount, only if its own proxy is still the active handler. Two components racing for the same action don’t accidentally remove each other’s installations. For non-React hosts using registerHandler directly, you own the unregistration lifecycle. If a handler is mid-flight when the component unmounts (e.g., still awaiting a capture), the responder reference may outlive the component. Either capture the responder in a ref and call responder.cancelled("...") from an unmount effect, or let the handler complete with its existing reference — the SDK doesn’t care, but the journey-side journey may time out if the responder never resolves.

Permission state

Browser-mediated permissions (camera, microphone, geolocation, etc.) are not automatically detected by the SDK — unlike iOS, which ships CameraDetector. Query the browser yourself via navigator.permissions.query() and pass the result through CapabilityInfo.permissionState.

Surfacing it to the journey

The key={permissionState} trick remounts the provider when the state changes — necessary because capabilities is snapshotted at attach time. If you’d rather keep the provider stable, switch to registerCustomCapability for the affected entries (it reads from the live capability map on every query).

Listening for permission changes

PermissionStatus exposes an onchange event for browsers that support it:

Capability negotiation

The journey iframe sends a capability.query request as it loads. The SDK’s built-in CapabilityQueryHandler responds automatically — you don’t register this yourself. The response combines capabilities from BridgeHostOptions with any registerCustomCapability entries. Typed-slot-style auto-declaration does not exist on web — explicit declaration is required for every capability you want to advertise.

Response shape

version is null when unset. permissionState is omitted when unset. environment is always "web" from this SDK.

Declaring “not supported” explicitly

Declaring supported: false is meaningful — it tells the journey “I know about this capability and I don’t fulfil it” rather than leaving the journey to infer that from the absence of an entry. Use this for capabilities the journey might expect on iOS/Android but that web hosts genuinely cannot provide:

Environment-specific behavior

Not all capabilities make sense on web. The journey can detect the environment from capability.query response and adapt its flow. Declare the capabilities you actually fulfil. Don’t declare supported: true for nfc.read unless you’ve actually wired up Web NFC.

Graceful degradation patterns

When a capability isn’t available, the journey has two options: fall back to an in-iframe equivalent, or skip the step. The host doesn’t need to do anything special — the journey handles the routing.

Pattern 1: Fall back to in-iframe capture

The journey checks capabilities and adapts:
If you don’t host a richer capture UI than the iframe can provide, simply don’t declare camera.document — the journey handles it natively.

Pattern 2: Permission-aware UX

The journey can read permissionState from the query response and surface a permission prompt before triggering the request:

Pattern 3: Explicit unsupported response

If the journey sends a request for a capability you’ve declared but cannot currently fulfil (e.g., camera unavailable on the device), respond with unsupported:
unsupported carries code: "UNSUPPORTED" and recoverable: false — the journey treats this as a final outcome, not retryable, and is expected to fall back or skip.

Busy guards on web

Unlike iOS and Android typed slots — which automatically reject concurrent requests with a BUSY error — the Web SDK’s handler registration does not enforce single-flight semantics. If the journey sends a second camera.document.capture request while the first is still in flight, your handler runs twice unless you guard against it explicitly. The recommended pattern uses a useRef for the active responder:
A few rules to keep this guard correct:
  • Always clear responderRef.current = null AFTER calling a terminal method. The responder enforces single-terminal at its own level, but your guard needs to know the slot is free again.
  • Cancel the in-flight responder on unmount — see “Cleanup on unmount” above. Leaving the journey waiting is the worst failure mode.
  • recoverable: true on the BUSY error signals to the journey that it can retry once the current capture completes. Use recoverable: false only if you’re certain the journey shouldn’t try again.
For multi-capability hosts, give each capability its own responder ref — don’t share state across camera.document.capture and camera.selfie.capture.

Dynamic capability updates

Capability state is dynamic by nature. Two situations to handle:

Capabilities added after mount

Use host.registerCustomCapability via useBridgeHost(). The journey can re-query at any time and will see the new entry.

Capabilities changing at mount-time props

capabilities from BridgeHostProviderProps is snapshotted when the host is built. To apply a change:
  • Recommended: force a remount via a changing key prop.
  • Alternative: use registerCustomCapability for the affected entries — they bypass the snapshot.

Re-querying capabilities

The journey is expected to re-query capabilities after significant state changes (returning from background, permission grants, etc.). If you’ve updated capability state, you can also host.sendEvent("host.capabilities.changed", {}) so the journey re-queries proactively — though the journey-side SDK does this implicitly on visibility change.

Next steps

  • Messaging — Request/response patterns and the bridge event catalogue.
  • Security — Origin allowlist, CSP, and permission delegation.
  • Troubleshooting — Diagnosing capability-related issues.
  • API Reference — Detailed signatures for BridgeHostProvider, hooks, and BridgeHost.