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 â 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âscapabilities prop declares what the host supports; useBridgeCapability attaches the handler that actually fulfils requests.
Declaring capabilities
Pass aCapabilityMap to the provider:
CapabilityInfo entry can include:
Registering handlers
UseuseBridgeCapability inside any descendant of the provider:
{ 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
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 â callhost.registerCustomCapability(id, version, handler) directly. It declares the capability AND registers the handler in one step.
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 constructorcapabilities map AND a registerCustomCapability call, the constructor wins. The query handler builds responses by merging in this order:
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
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 aHANDLER_FAILURE response and routes the error to delegate.onError:
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 callsresponder.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 shipsCameraDetector. Query the browser yourself via navigator.permissions.query() and pass the result through CapabilityInfo.permissionState.
Surfacing it to the journey
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 acapability.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
Declaringsupported: 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 fromcapability.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:camera.document â the journey handles it natively.
Pattern 2: Permission-aware UX
The journey can readpermissionState 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 withunsupported:
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 aBUSY 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:
- Always clear
responderRef.current = nullAFTER 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: trueon the BUSY error signals to the journey that it can retry once the current capture completes. Userecoverable: falseonly if youâre certain the journey shouldnât try again.
camera.document.capture and camera.selfie.capture.
Dynamic capability updates
Capability state is dynamic by nature. Two situations to handle:Capabilities added after mount
Usehost.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
keyprop. - Alternative: use
registerCustomCapabilityfor 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 alsohost.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, andBridgeHost.