despia() call, the same call exposed by the despia-native NPM package.
How despia() works
The web app calls native code through a single global function published by despia-native:
window.despia. The native runtime intercepts the assignment, dispatches to the matching handler, and pushes results back to the WebView. There are two return channels: window.<varName> for awaited responses (read by despia-native via the variable observer with a 30-second timeout), and direct calls to window.<callback>(payload) for fire-and-listen and streaming patterns.
This is the contract every Extension implements. You expose a URL scheme, you write to a variable or call a window function, and the web app reads the response. You do not implement any JavaScript-side bridge.
Three response patterns
There are exactly three ways an Extension can communicate back to the web app. The choice is determined by whether the operation has a bounded duration, not by how complex the response is.Variable
For deterministic native work where you can guarantee a result inside 30 seconds. Reading device sensors (battery, geolocation), reading SDK state (HealthKit step count, active RevenueCat entitlements, contacts), running a cryptographic operation, hitting your own backend with a timeout. The web app awaits one line and gets a value ornull.
Fire and listen
For any operation with no enforceable upper time bound. The web app fires the action and registers awindow.<callback> listener. The native side calls the listener when work finishes, however long that takes.
This is the right pattern for:
- User input: alert prompts, picker dialogs, photo / file pickers, biometric prompts, OAuth login windows, share sheets
- Long-running native work: PDF export, video transcoding, large file uploads, ML inference
- Anything that calls into another app: deep-linking out to settings or the camera, returning later
on_ (e.g. on_prompt_done, on_purchase_complete, on_export_finished). The despia.event call is a no-op if the listener isn’t defined, which is why listeners must be registered before firing.
Stream / event
Same mechanism as fire-and-listen, but the native side calls the listener repeatedly. Use for live BLE scan results, push notification deliveries, customer-info change observers, and similar genuine streams.event while still resolving an awaited final-result variable, as long as the final result is bounded.
Picking the right pattern
Use this decision flow for every action you build.window.<callback> definition. The cost of mis-using a variable for an unbounded operation is a 30-second hang for every user every time.
Folder layout
despia-extension-<scheme> matching the scheme field in the spec.
Delivery
Two delivery paths. Both produce identical builds.API reference: despia global
The Extension SDK exposes a single global called despia in both Swift and Kotlin source files. It is implicit, no import needed. The full surface:
despia.action(name, closure)
Registers a handler for despia('<scheme>://<name>?...'). Closures must register at file scope.
despia.hydration(closure)
Runs once per WebView page load. Use for SDK initialisation. Make it idempotent.
despia.params.<key>
Reads a typed URL param. The type is determined by the spec’s declaration for that param.
despia.env.<key>
Reads a value configured in the dashboard. Always returns String. Empty string if no value is configured. Defaults declared in the spec apply automatically.
despia.files["<key>"].data and .array
Reads binary data uploaded via window.native.set_file() from the web app. The web app sends a @file/<uuid> token in the URL; the runtime resolves it to native bytes before your closure runs.
despia.variable(name, value)
Sets window.<name> = value. This is the primary response channel. The web app reads it with await despia('scheme://...', ['name']).
Failure semantics: write null to signal failure. despia-native resolves the awaited promise with null for that key, which is the standard “I tried and it didn’t work” signal across all extensions.
Date (use ISO strings), no URL (use String).
despia.event(callbackName, payload)
Calls window.<callbackName>(payload) if defined. Use this for the fire-and-listen pattern (single async response that may exceed 30 seconds) and for streams (multiple pushes over time). No-op if the web app hasn’t defined the callback.
Variable extension: battery level
The smallest possible extension. One action, one variable. Reads battery level. Resolves immediately.despia-extension.json
Sources/ios/battery.swift
Sources/android/battery.kt
Web app
Fire-and-listen extension: alert prompt
The canonical user-input case. The web app shows a native text-input prompt and waits for the user to type. Because the user has no SLA, this must be fire-and-listen, not variable. A user reading the dialog for 31 seconds would otherwise hang the call.despia-extension.json
returns is null because the action does not write a variable. The completion is delivered through the on_prompt_done event when (or if) the user dismisses the dialog.
Sources/ios/alert.swift
Sources/android/alert.kt
on_prompt_done exactly once, on every exit path: OK, Cancel, and (Android) tap-outside-to-dismiss. Failing to emit on any path leaves the listener hanging forever.
Web app
Stream extension: BLE scanner
Combines the variable channel (final scan result, bounded by theduration param) with the event channel (live discoveries during the scan). Also demonstrates typed params, dashboard config, and capabilities.
despia-extension.json
Sources/ios/ble.swift
Sources/android/ble.kt
Web app
despia-extension.json reference
Complete schema. Every field, every type.
Top-level
dependencies
package (SPM git URL), version (semver), products (SPM product names). Android entry: artifact (Maven group:name:version). System frameworks need no entry.
vars
hosts
Required-param enforcement: if a required param is missing, the closure never runs. The runtime writes
{ error: "Missing required param: <name>" } to returns.varName if defined.
events
autoInject
capabilities
iOS entry:
Android entry:
Param decoding
The runtime decodes URL params into typed values before invoking the closure.
Pre-decoding normalisation: trims whitespace and zero-width unicode (U+200B, U+FEFF, U+00A0); decodes
+ as space unless the value looks like JSON; applies percent-decoding once, then again if still encoded.
File bridge
For binary data or JSON over 8KB. The web app stores data in native memory, sends a UUID through the URL.Reserved schemes
Owned by the runtime. Build fails if yourscheme matches:
Behaviour rules
These describe how the runtime and thedespia-native SDK behave. Build extensions to match.
Variables are for bounded operations only. A variable response is only correct when you can guarantee completion in under 30 seconds on every device. Anything that waits for the user (alert prompts, picker dialogs, biometric confirmation sheets, OAuth windows, file pickers, share sheets) must use fire-and-listen via despia.event. Anything that waits on a network call without a strict timeout, or any potentially long native work (PDF export, video transcode, large uploads, ML inference), must also use fire-and-listen. The 30-second timeout is for deterministic operations like reading sensors, reading SDK state, or running a local cryptographic operation.
The 30-second window. despia-native waits 30 seconds for awaited variables. If your action takes longer, the web side times out and resolves with undefined. For longer or unbounded work, use fire-and-listen instead.
null means failure. When despia-native sees window.<varName> === null, it resolves immediately with null for that key. Use this as the universal failure signal across all variable returns. For event callbacks, include an error field in the payload instead.
Pre-clearing. Before each await despia('scheme://', ['var']) call, despia-native deletes window[var] to avoid stale resolves. Your action must always set the variable, even on failure (set it to null). Otherwise the call hangs until timeout.
Multi-variable awaits. await despia('scheme://', ['a', 'b', 'c']) waits for all three. Each must be defined and non-null and non-"n/a". If any one stays unset, the call resolves to {} after 5 minutes. Set every promised variable in your action.
Listeners must be defined before firing. For fire-and-listen, register window.<callback> before calling despia(...). The native side may complete before the JS event loop runs again, and despia.event is a no-op if the listener isn’t defined.
Closures register at file scope. despia.action and despia.hydration calls go at the top level of the file, not inside functions or classes. The runtime auto-discovers them on file load.
State lives in singletons. Use static let shared (Swift) or private object (Kotlin) for state across calls. Files stay loaded for the WebView lifetime.
Hydration is idempotent. It runs on every page load including SPA route reloads. Check if SDK.isConfigured before re-initialising.
Payloads are JSON-only. Strings, numbers, booleans, arrays, string-keyed maps. No Date (use ISO strings), no URL (use String), no custom classes.
The web app never imports anything besides despia-native. No script tags, no extension-specific packages. The single import gives access to every extension.
What the build system handles
Resources
despia-native on NPM
Web-side SDK