# Mobile apps

> Run face liveness, document and selfie capture inside your iOS, Android, React Native or Flutter app, and ship it to production.

Speedmap capture runs in your own app, not only from a link you send. The SDKs load the Speedmap hosted capture page in a WebView, give it the camera, and pass progress back to your code. The camera screens, liveness checks and uploads are the same ones the hosted link uses, so improvements reach your app without an app release.

| Platform | Package | Built on |
| --- | --- | --- |
| React Native | `@speedmap/capture-react-native` | `react-native-webview` |
| Flutter | `speedmap_capture` | `webview_flutter`, `permission_handler` |
| iOS (Swift) | Copy-paste view controller below | `WKWebView` |
| Android (Kotlin) | Copy-paste activity below | `WebView` |
| Web | `@speedmap/capture` or a redirect to the hosted page | Browser |

### 1. Create a capture session on your server

Call `POST /v1/capture-sessions` with your secret key. Keep the `id`; send the `url` to the app. Never put an API key in an app.

_cURL_

```bash
curl -X POST "http://localhost:4000/v1/capture-sessions" \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "steps": [
    "liveness",
    "document"
  ],
  "reason": "financialProducts",
  "brand": {
    "name": "Skaikru Lending",
    "color": "#0F766E"
  }
}'
```

_Node.js_

```javascript
const res = await fetch("http://localhost:4000/v1/capture-sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPEEDMAP_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "steps": [
      "liveness",
      "document"
    ],
    "reason": "financialProducts",
    "brand": {
      "name": "Skaikru Lending",
      "color": "#0F766E"
    }
  }),
});

const session = await res.json();
if (!res.ok) throw new Error(session.error.message);
console.log(session);
```

_Python_

```python
import os, requests

res = requests.post(
    "http://localhost:4000/v1/capture-sessions",
    headers={
        "Authorization": f"Bearer {os.environ['SPEEDMAP_API_KEY']}",
    },
    json={
        "steps": [
            "liveness",
            "document"
        ],
        "reason": "financialProducts",
        "brand": {
            "name": "Skaikru Lending",
            "color": "#0F766E"
        }
    },
    timeout=30,
)
res.raise_for_status()
print(res.json())
```

### 2. Open the capture URL in the SDK

The response has `url`, for example `https://speedmap-delta.vercel.app/capture/jw2tHht1...`. Pass it to the SDK component, view controller or activity. For a liveness session (`POST /v1/liveness/sessions`) use `capture_url`.

### 3. Handle the result

The SDK reports `complete` with the session id. Your server then reads the outcome with `GET /v1/capture-sessions/{id}` or the `capture_session.completed` [webhook](https://speedmap-delta.vercel.app/docs/webhooks). The app never receives images, identity numbers or verification outcomes.

## React Native

Requires React Native 0.72 or later and `react-native-webview` 13.6 or later.

```bash
npm install @speedmap/capture-react-native react-native-webview
cd ios && pod install
```

**Camera permission**

_iOS Info.plist_

```xml
<key>NSCameraUsageDescription</key>
<string>We use the camera to confirm your identity with a short face check and a photo of your ID.</string>
```

_AndroidManifest.xml_

```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
```

_Expo app.json_

```json
{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "We use the camera to confirm your identity with a short face check and a photo of your ID."
      }
    },
    "android": { "permissions": ["android.permission.CAMERA"] }
  }
}
```

```tsx
import { SafeAreaView } from "react-native";
import { SpeedmapCapture } from "@speedmap/capture-react-native";

export function VerifyScreen({ url, navigation }) {
  // url: the capture URL your server got from POST /v1/capture-sessions
  return (
    <SafeAreaView style={{ flex: 1 }}>
      <SpeedmapCapture
        url={url}
        locale="en"
        theme="auto"
        appearance={{ colors: { accent: "#0F766E" }, radius: 12 }}
        onComplete={({ sessionId }) => {
          // Tell your server; it fetches the result with its API key.
          api.post("/kyc/complete", { sessionId });
          navigation.replace("Submitted");
        }}
        onCancel={() => navigation.goBack()}
        onError={(e) => {
          // recoverable: the page already shows a retry button.
          if (!e.recoverable) navigation.replace("VerificationProblem", { code: e.code });
        }}
      />
    </SafeAreaView>
  );
}
```

On Android the component asks for the camera permission before it loads the page. On iOS it sets `allowsInlineMediaPlayback` and grants the camera to the capture origin on iOS 15 and later. With Expo, use a development or EAS build, install the WebView with `npx expo install react-native-webview`, and add the permission strings to `app.json`; `expo-camera` is not needed.

`useSpeedmapCapture()` tracks the status (`loading`, `ready`, `in_progress`, `completed`, `error`, `cancelled`) if you prefer state to callbacks.

## Flutter

Requires Flutter 3.19 or later, iOS 15 or later and Android 7 (API 24) or later. Add the same Info.plist and AndroidManifest entries as above, and enable the camera permission for `permission_handler` in your Podfile.

_pubspec.yaml_

```yaml
dependencies:
  speedmap_capture: ^0.1.0
```

_ios/Podfile_

```ruby
post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
    target.build_configurations.each do |config|
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', 'PERMISSION_CAMERA=1']
    end
  end
end
```

```dart
import 'package:speedmap_capture/speedmap_capture.dart';

class CaptureScreen extends StatelessWidget {
  const CaptureScreen({super.key, required this.url});
  final String url; // from your server

  @override
  Widget build(BuildContext context) => Scaffold(
        body: SafeArea(
          child: SpeedmapCapture(
            url: url,
            locale: 'en',
            theme: SpeedmapTheme.auto,
            onComplete: (m) => Navigator.of(context).pop(m.sessionId),
            onCancel: (_) => Navigator.of(context).pop(),
            onError: (e) {
              if (!e.recoverable) Navigator.of(context).pop();
            },
          ),
        ),
      );
}
```

The widget grants the camera (never the microphone) only to the capture origin, and receives messages on the `SpeedmapFlutter` JavaScript channel.

## iOS (Swift)

No package to install: add this view controller to your app. It needs iOS 14.5 or later (15 or later for the automatic camera grant) and `NSCameraUsageDescription` in Info.plist.

_SpeedmapCaptureViewController.swift_

```swift
import AVFoundation
import UIKit
import WebKit

/// Presents the Speedmap capture page for one capture session.
final class SpeedmapCaptureViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
    var onComplete: ((_ sessionId: String?) -> Void)?
    var onCancel: (() -> Void)?
    var onError: ((_ code: String, _ recoverable: Bool) -> Void)?

    private let captureURL: URL
    private var webView: WKWebView!

    /// captureURL: the url your server got from POST /v1/capture-sessions.
    init(captureURL: URL) {
        var parts = URLComponents(url: captureURL, resolvingAgainstBaseURL: false)!
        parts.queryItems = (parts.queryItems ?? []) + [URLQueryItem(name: "embed", value: "1")]
        self.captureURL = parts.url!
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func loadView() {
        let config = WKWebViewConfiguration()
        config.allowsInlineMediaPlayback = true                 // camera preview inside the page
        config.mediaTypesRequiringUserActionForPlayback = []
        config.userContentController.add(WeakMessageHandler(self), name: "speedmap")

        webView = WKWebView(frame: .zero, configuration: config)
        webView.navigationDelegate = self
        webView.uiDelegate = self
        webView.scrollView.contentInsetAdjustmentBehavior = .never // the page applies safe-area insets itself
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // Ask for the camera first so the system prompt appears over your app, not mid-flow.
        AVCaptureDevice.requestAccess(for: .video) { _ in
            DispatchQueue.main.async { self.webView.load(URLRequest(url: self.captureURL)) }
        }
    }

    deinit { webView?.configuration.userContentController.removeScriptMessageHandler(forName: "speedmap") }

    // iOS 15+: give the capture page the camera without a second, web-style prompt.
    @available(iOS 15.0, *)
    func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                 initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
                 decisionHandler: @escaping (WKPermissionDecision) -> Void) {
        decisionHandler(origin.host == captureURL.host && type == .camera ? .grant : .deny)
    }

    // Stay on the capture origin; anything else (for example your success_url) is not followed.
    func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        let url = action.request.url
        let allowed = url?.host == captureURL.host || url?.scheme == "about" || url?.scheme == "blob"
        decisionHandler(allowed ? .allow : .cancel)
    }

    func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
        guard message.frameInfo.isMainFrame,
              message.frameInfo.securityOrigin.host == captureURL.host,
              let body = message.body as? String,
              let msg = try? JSONDecoder().decode(SpeedmapMessage.self, from: Data(body.utf8)),
              msg.source == "speedmap" else { return }
        switch msg.type {
        case "complete": onComplete?(msg.session_id)
        case "cancel": onCancel?()
        case "error": onError?(msg.code ?? "internal", msg.recoverable ?? false)
        default: break // "ready" and "event": progress, e.g. msg.event == "step.complete"
        }
    }
}

struct SpeedmapMessage: Decodable {
    let source: String
    let version: Int
    let type: String          // ready | event | complete | error | cancel
    let flow: String?
    let session_id: String?
    let status: String?
    let steps: [String]?
    let event: String?
    let step: String?
    let reference_id: String?
    let code: String?
    let recoverable: Bool?
}

/// WKUserContentController retains its handlers; this breaks the cycle.
private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
    weak var target: WKScriptMessageHandler?
    init(_ target: WKScriptMessageHandler) { self.target = target }
    func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
        target?.userContentController(c, didReceive: m)
    }
}
```

_Present it_

```swift
let capture = SpeedmapCaptureViewController(captureURL: url)
capture.modalPresentationStyle = .fullScreen
capture.onComplete = { [weak self] sessionId in
    self?.dismiss(animated: true)
    // Send sessionId to your server; it reads the result with its API key.
}
capture.onCancel = { [weak self] in self?.dismiss(animated: true) }
present(capture, animated: true)
```

## Android (Kotlin)

Add this activity to your app and declare it in AndroidManifest.xml together with the `CAMERA` and `INTERNET` permissions. It needs `androidx.appcompat` and `androidx.activity`.

_SpeedmapCaptureActivity.kt_

```kotlin
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.webkit.*
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject

/** Shows the Speedmap capture page for one capture session. Start with EXTRA_URL = the session url. */
class SpeedmapCaptureActivity : AppCompatActivity() {
    companion object {
        const val EXTRA_URL = "speedmap_url"
        const val RESULT_SESSION_ID = "session_id"
    }

    private lateinit var webView: WebView
    private lateinit var captureUrl: Uri
    private var pendingRequest: PermissionRequest? = null

    private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        pendingRequest?.let { if (granted) it.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else it.deny() }
        pendingRequest = null
    }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        captureUrl = Uri.parse(intent.getStringExtra(EXTRA_URL)).buildUpon()
            .appendQueryParameter("embed", "1").build()

        webView = WebView(this)
        setContentView(webView)
        // Edge-to-edge (Android 15+): keep the page clear of the status and navigation bars.
        ViewCompat.setOnApplyWindowInsetsListener(webView) { v, insets ->
            val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
            v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
            WindowInsetsCompat.CONSUMED
        }

        webView.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
            mediaPlaybackRequiresUserGesture = false   // camera preview starts without a tap
            allowFileAccess = false
            allowContentAccess = false
        }
        webView.addJavascriptInterface(Bridge(), "SpeedmapAndroid")

        webView.webViewClient = object : WebViewClient() {
            // Stay on the capture origin; anything else (for example your success_url) is not followed.
            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
                request.url.host != captureUrl.host
        }
        webView.webChromeClient = object : WebChromeClient() {
            // getUserMedia from the page: camera only, capture origin only.
            override fun onPermissionRequest(request: PermissionRequest) = runOnUiThread {
                val camera = PermissionRequest.RESOURCE_VIDEO_CAPTURE
                if (request.origin.host != captureUrl.host || !request.resources.contains(camera)) {
                    request.deny()
                } else if (ContextCompat.checkSelfPermission(this@SpeedmapCaptureActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
                    request.grant(arrayOf(camera))
                } else {
                    pendingRequest = request
                    cameraPermission.launch(Manifest.permission.CAMERA)
                }
            }
        }

        onBackPressedDispatcher.addCallback(this) { setResult(RESULT_CANCELED); finish() }
        webView.loadUrl(captureUrl.toString())
    }

    override fun onDestroy() {
        webView.destroy()
        super.onDestroy()
    }

    private inner class Bridge {
        /** Called by the page on a background thread with one JSON message. */
        @JavascriptInterface
        fun postMessage(json: String) = runOnUiThread {
            // Only trust messages while the capture origin is loaded.
            if (Uri.parse(webView.url ?: "").host != captureUrl.host) return@runOnUiThread
            val msg = runCatching { JSONObject(json) }.getOrNull() ?: return@runOnUiThread
            if (msg.optString("source") != "speedmap") return@runOnUiThread
            when (msg.optString("type")) {
                "complete" -> {
                    setResult(RESULT_OK, Intent().putExtra(RESULT_SESSION_ID, msg.optString("session_id")))
                    finish()
                }
                "cancel" -> { setResult(RESULT_CANCELED); finish() }
                "error" -> if (!msg.optBoolean("recoverable")) { setResult(RESULT_CANCELED); finish() }
                else -> Unit // "ready" and "event": progress
            }
        }
    }
}
```

_Start it_

```kotlin
private val capture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    val sessionId = result.data?.getStringExtra(SpeedmapCaptureActivity.RESULT_SESSION_ID)
    if (result.resultCode == RESULT_OK && sessionId != null) {
        // Send sessionId to your server; it reads the result with its API key.
    }
}

fun startVerification(url: String) {
    capture.launch(Intent(this, SpeedmapCaptureActivity::class.java).putExtra(SpeedmapCaptureActivity.EXTRA_URL, url))
}
```

`addJavascriptInterface` exposes the bridge to whatever page the WebView shows, which is why the activity never leaves the capture origin and checks the loaded URL before trusting a message. On AndroidX WebKit you can use `WebViewCompat.addWebMessageListener` with the name `SpeedmapAndroid` and your capture origin instead; the page uses either.

## Any other WebView

The SDKs above are thin wrappers. Any WebView works if it meets these requirements:

- JavaScript and DOM storage enabled, and the page served over https.
- Inline media playback allowed, with no user gesture required for playback.
- Camera access granted to the capture origin: `onPermissionRequest` on Android (`RESOURCE_VIDEO_CAPTURE`), `requestMediaCapturePermissionFor` on iOS 15+, plus the app-level camera permission.
- Navigation kept on the capture origin.
- A bridge named `ReactNativeWebView`, `webkit.messageHandlers.speedmap`, `SpeedmapAndroid` or `SpeedmapFlutter` with a `postMessage(string)` method, if you want progress messages.

In-app browser tabs (`SFSafariViewController`, Chrome Custom Tabs) also work and need no camera setup, but have no message bridge. Set `success_url` on the session to a link that returns the person to your app, and confirm the result on your server.

Camera access in WebViews needs iOS 14.5 or later and an up-to-date Android System WebView or Chrome. Older devices see a clear message asking them to update or use another device.

## URL parameters

The SDKs add these to the capture URL. You can add them yourself when you open it in a WebView or browser.

| Parameter | Values | Effect |
| --- | --- | --- |
| `embed` | `1` | Hides the web header and footer and lays the page out full-bleed for a WebView. |
| `lang` | `en`, `pcm`, `ha`, `yo`, `ig` | Language. Defaults to the session `locale`, then the device language. |
| `theme` | `light`, `dark`, `auto` | Colour scheme. Default: the device setting. |
| `appearance` | URL-encoded JSON | White-label look for the capture screens (colours, radius, font). Invalid values are ignored. |

## Message schema

Every bridge receives the same JSON string. Messages carry progress only: never images, names, identity numbers or outcomes.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `source` | string | Yes | Always `speedmap`. Ignore anything else. |
| `version` | number | Yes | Schema version, currently `1`. |
| `type` | string | Yes | `ready`, `event`, `complete`, `error` or `cancel`. |
| `flow` | string | Yes | `capture` for capture-session links (`/capture/{token}`), `verify` for verification-session links (`/verify/{token}`). |
| `session_id` | string | No | The session id (`cap_…` or `vs_…`), once the session has loaded. |
| `status` | string | No | Session status as the page last saw it, e.g. `created`, `completed`, `expired`. |
| `steps` | string[] | No | `ready` only: the steps the person will be asked for. |
| `event` | string | No | `event` only: `step.start`, `step.complete`, `camera.start`, `camera.stop`, `retry` or `upload.complete`. |
| `step` | string | No | The step concerned, e.g. `liveness`, `document`, `selfie`. |
| `reference_id` | string | No | On `step.complete` when available: the stored check, `spm_…`. Look it up with your API key. |
| `code` | string | No | `error` only. See the table below. |
| `recoverable` | boolean | No | `error` only. `true` when the page already offers the person a retry; keep the screen open. |

**Examples**

_ready_

```json
{
  "source": "speedmap",
  "version": 1,
  "type": "ready",
  "flow": "capture",
  "session_id": "cap_9d9d78ebe9f53966",
  "status": "created",
  "steps": [
    "liveness",
    "document"
  ]
}
```

_event_

```json
{
  "source": "speedmap",
  "version": 1,
  "type": "event",
  "flow": "capture",
  "session_id": "cap_9d9d78ebe9f53966",
  "event": "step.start",
  "step": "liveness"
}
```

_complete_

```json
{
  "source": "speedmap",
  "version": 1,
  "type": "complete",
  "flow": "capture",
  "session_id": "cap_9d9d78ebe9f53966",
  "status": "completed"
}
```

| code | Meaning | Recoverable |
| --- | --- | --- |
| `invalid_link` | The token in the URL does not match a session. | No |
| `session_expired` | The session expired. Create a new one. | No |
| `load_failed` | The session could not be loaded (network or server). | Yes |
| `camera_denied` | The person or the system refused the camera. | Yes |
| `no_camera` | No usable camera was found. | Yes |
| `unsupported_browser` | The WebView cannot open the camera (old OS or WebView). | No |
| `retry_limit` | Too many failed attempts at a step. | Yes |
| `attempts_exhausted` | A verification-session step used all its attempts. | No |
| `upload_failed` | A capture could not be uploaded. | Yes |
| `internal` | Anything else. | Yes |

`cancel` means the person closed the camera or cancelled the session. `complete` is sent once, when every step has been received; it is a signal, not proof. Confirm the outcome on your server.

## Security

- Create sessions on your server. The capture URL is a single-use credential for one person: do not log it, cache it or share it.
- Trust messages only from the capture origin, and only in the main frame.
- The hosted pages cannot be framed by other websites. On the web, redirect to the capture URL or use `@speedmap/capture` in your page.

## Testing

Sessions created with an `sk_test_` key run in the [sandbox](https://speedmap-delta.vercel.app/docs/sandbox): the page shows a test-mode banner and nothing is billed. To test on a device against a local server, use your computer's LAN address (the Android emulator reaches the host at `10.0.2.2`); the SDKs accept http only for local and LAN addresses.

---

Source: https://speedmap-delta.vercel.app/docs/mobile-sdks
